diff --git a/.github/workflows/push-release-tagged-server.yml b/.github/workflows/push-release-tagged-server.yml new file mode 100644 index 000000000..26ccf3a4d --- /dev/null +++ b/.github/workflows/push-release-tagged-server.yml @@ -0,0 +1,45 @@ +name: Push Release Tagged Server + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +env: + IMAGE_NAME: citrineos-server + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + run: docker build . --file ./Server/docker/Dockerfile --tag $IMAGE_NAME + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push image + run: | + IMAGE_ID=ghcr.io/${{ github.repository_owner }}/${IMAGE_NAME} + IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') + + VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') + VERSION=$(echo $VERSION | sed -e 's/^v//') + echo IMAGE_ID=$IMAGE_ID + echo VERSION=$VERSION + + docker tag $IMAGE_NAME $IMAGE_ID:$VERSION + docker push $IMAGE_ID:$VERSION diff --git a/.github/workflows/test-build-server.yml b/.github/workflows/test-build-server.yml new file mode 100644 index 000000000..7a8e21937 --- /dev/null +++ b/.github/workflows/test-build-server.yml @@ -0,0 +1,20 @@ +name: Build Server on Pull Request + +on: + pull_request: + +env: + IMAGE_NAME: citrineos-server + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + run: docker build . --file ./Server/deploy.Dockerfile --tag $IMAGE_NAME diff --git a/.gitignore b/.gitignore index fecf85fe6..9886c801d 100644 --- a/.gitignore +++ b/.gitignore @@ -170,6 +170,9 @@ dist # TernJS port file .tern-port +# Stores VSCode configurations +.vscode + # Stores VSCode versions used for testing VSCode extensions .vscode-test @@ -204,4 +207,99 @@ package-lock.json # PostgreSQL /Server/data/postgresql -/Swarm/data/postgresql \ No newline at end of file +/Swarm/data/postgresql + +# Created by https://www.toptal.com/developers/gitignore/api/intellij+all +# Edit at https://www.toptal.com/developers/gitignore?templates=intellij+all + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +#!.idea/codeStyles // todo potentially comment back in when vscode and intellij configs are supported +#!.idea/runConfigurations // todo potentially comment back in when vscode and intellij configs are supported + +# End of https://www.toptal.com/developers/gitignore/api/intellij+all + +data diff --git a/00_Base/package.json b/00_Base/package.json index ee23320d0..952f2865f 100644 --- a/00_Base/package.json +++ b/00_Base/package.json @@ -2,17 +2,17 @@ "name": "@citrineos/base", "version": "1.0.0", "description": "The base module for OCPP v2.0.1 including all interfaces. This module is not intended to be used directly, but rather as a dependency for other modules.", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "lib" + "dist" ], "scripts": { "prepublish": "npx eslint ./src", - "prepare": "npm run build", - "build": "tsc", "generate-interfaces": "node json-schema-processor.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "compile": "npm run clean && tsc -p tsconfig.json", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo" }, "keywords": [ "ocpp", @@ -37,5 +37,6 @@ "tslog": "^4.9.2", "uuid": "^9.0.0", "zod": "^3.22.2" - } + }, + "workspace": "../" } diff --git a/00_Base/src/config/BootConfig.ts b/00_Base/src/config/BootConfig.ts index a18210c30..101b985b8 100644 --- a/00_Base/src/config/BootConfig.ts +++ b/00_Base/src/config/BootConfig.ts @@ -28,4 +28,13 @@ export interface BootConfig { * Also declared in SystemConfig. If absent, SystemConfig value is used. */ bootWithRejectedVariables?: boolean; -} \ No newline at end of file +} + +/** + * Cache boot status is used to keep track of the overall boot process for Rejected or Pending. + * When Accepting a boot, blacklist needs to be cleared if and only if there was a previously + * Rejected or Pending boot. When starting to configure charger, i.e. sending GetBaseReport or + * SetVariables, this should only be done if configuring is not still ongoing from a previous + * BootNotificationRequest. Cache boot status mediates this behavior. + */ +export const BOOT_STATUS = "boot_status"; \ No newline at end of file diff --git a/00_Base/src/config/defineConfig.ts b/00_Base/src/config/defineConfig.ts index 1e690b023..1ffa030b0 100644 --- a/00_Base/src/config/defineConfig.ts +++ b/00_Base/src/config/defineConfig.ts @@ -3,20 +3,93 @@ // // SPDX-License-Identifier: Apache 2.0 -import { type SystemConfig, systemConfigSchema, SystemConfigInput } from "./types"; +import {type SystemConfig, SystemConfigInput, systemConfigSchema} from "./types"; +/** + * Defines the application configuration by merging input configuration which is defined in a file with environment variables. + * Takes environment variables over predefined + * @param inputConfig The file defined input configuration. + * @returns The final system configuration. + * @throws Error if required environment variables are not set or if there are parsing errors. + */ export function defineConfig(inputConfig: SystemConfigInput): SystemConfig { - if (!inputConfig.data.sequelize.username) { - if (process.env.CITRINEOS_DB_USERNAME) - inputConfig.data.sequelize.username = process.env.CITRINEOS_DB_USERNAME; - else - throw new Error('CITRINEOS_DB_USERNAME must be set if username not provided in config'); + const appConfig = mergeConfigFromEnvVars(inputConfig, process.env); + + validateFinalConfig(appConfig); + + return systemConfigSchema.parse(appConfig); +} + +/** + * Finds a case-insensitive match for a key in an object. + * @param obj The object to search. + * @param targetKey The target key. + * @returns The matching key or undefined. + */ +function findCaseInsensitiveMatch(obj: Record, targetKey: string): string | undefined { + const lowerTargetKey = targetKey.toLowerCase(); + return Object.keys(obj).find(key => key.toLowerCase() === lowerTargetKey); +} + +/** + * Merges configuration from environment variables into the default configuration. Allows any to keep it as generic as possible. + * @param defaultConfig The default configuration. + * @param envVars The environment variables. + * @returns The merged configuration. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mergeConfigFromEnvVars>(defaultConfig: T, envVars: NodeJS.ProcessEnv): T { + const config: T = {...defaultConfig}; + + const prefix = "citrineos_"; + + for (const [fullEnvKey, value] of Object.entries(envVars)) { + if (!value) continue; + const lowercaseEnvKey = fullEnvKey.toLowerCase(); + console.log(lowercaseEnvKey); + if (lowercaseEnvKey.startsWith(prefix)) { + const envKeyWithoutPrefix = lowercaseEnvKey.substring(prefix.length); + const path = envKeyWithoutPrefix.split('_'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let currentConfigPart: any = config; + + for (let i = 0; i < path.length - 1; i++) { + const part = path[i]; + const matchingKey = findCaseInsensitiveMatch(currentConfigPart, part); + if (matchingKey && typeof currentConfigPart[matchingKey] === 'object') { + currentConfigPart = currentConfigPart[matchingKey]; + } else { + currentConfigPart[part] = {}; + currentConfigPart = currentConfigPart[part]; + } + } + + const finalPart = path[path.length - 1]; + const keyToUse = findCaseInsensitiveMatch(currentConfigPart, finalPart) || finalPart; + + try { + currentConfigPart[keyToUse] = JSON.parse(value as string); + } catch { + console.error(`Error parsing value '${value}' for environment variable '${fullEnvKey}'.`); + currentConfigPart[keyToUse] = value; + } + } + } + return config as T; +} + + +/** + * Validates the system configuration to ensure required properties are set. + * @param finalConfig The final system configuration. + * @throws Error if required properties are not set. + */ +function validateFinalConfig(finalConfig: SystemConfigInput) { + if (!finalConfig.data.sequelize.username) { + throw new Error('CITRINEOS_DATA_SEQUELIZE_USERNAME must be set if username not provided in config'); } - if (!inputConfig.data.sequelize.password) { - if (process.env.CITRINEOS_DB_PASSWORD) - inputConfig.data.sequelize.password = process.env.CITRINEOS_DB_PASSWORD; - else - throw new Error('CITRINEOS_DB_PASSWORD must be set if password not provided in config'); + if (!finalConfig.data.sequelize.password) { + throw new Error('CITRINEOS_DATA_SEQUELIZE_PASSWORD must be set if password not provided in config'); } - return systemConfigSchema.parse(inputConfig); } \ No newline at end of file diff --git a/00_Base/src/config/types.ts b/00_Base/src/config/types.ts index 0729b19da..76ae08616 100644 --- a/00_Base/src/config/types.ts +++ b/00_Base/src/config/types.ts @@ -4,11 +4,31 @@ // SPDX-License-Identifier: Apache 2.0 import { z } from "zod"; -import { RegistrationStatusEnumType } from "../ocpp/model/enums"; +import { RegistrationStatusEnumType } from "../ocpp/model"; import { EventGroup } from ".."; +// TODO: Refactor other objects out of system config, such as certificatesModuleInputSchema etc. +export const websocketServerInputSchema = z.object({ + // TODO: Add support for tenant ids on server level for tenant-specific behavior + id: z.string().optional(), + host: z.string().default('localhost').optional(), + port: z.number().int().positive().default(8080).optional(), + pingInterval: z.number().int().positive().default(60).optional(), + protocol: z.string().default('ocpp2.0.1').optional(), + securityProfile: z.number().int().min(0).max(3).default(0).optional(), + allowUnknownChargingStations: z.boolean().default(false).optional(), + tlsKeysFilepath: z.string().optional(), + tlsCertificateChainFilepath: z.string().optional(), + mtlsCertificateAuthorityRootsFilepath: z.string().optional(), + mtlsCertificateAuthorityKeysFilepath: z.string().optional() +}); + export const systemConfigInputSchema = z.object({ env: z.enum(["development", "production"]), + centralSystem: z.object({ + host: z.string().default("localhost").optional(), + port: z.number().int().positive().default(8081).optional(), + }), modules: z.object({ certificates: z.object({ endpointPrefix: z.string().default(EventGroup.Certificates).optional(), @@ -25,7 +45,7 @@ export const systemConfigInputSchema = z.object({ endpointPrefix: z.string().default(EventGroup.Configuration).optional(), host: z.string().default("localhost").optional(), port: z.number().int().positive().default(8081).optional(), - }), // Configuration module is required + }), evdriver: z.object({ endpointPrefix: z.string().default(EventGroup.EVDriver).optional(), host: z.string().default("localhost").optional(), @@ -50,7 +70,9 @@ export const systemConfigInputSchema = z.object({ endpointPrefix: z.string().default(EventGroup.Transactions).optional(), host: z.string().default("localhost").optional(), port: z.number().int().positive().default(8081).optional(), - }), // Transactions module is required + costUpdatedInterval: z.number().int().positive().default(60).optional(), + sendCostUpdatedOnMeterValue: z.boolean().default(false).optional(), + }) }), data: z.object({ sequelize: z.object({ @@ -58,8 +80,8 @@ export const systemConfigInputSchema = z.object({ port: z.number().int().positive().default(5432).optional(), database: z.string().default('csms').optional(), dialect: z.any().default('sqlite').optional(), - username: z.string().optional(), - password: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), storage: z.string().default('csms.sqlite').optional(), sync: z.boolean().default(false).optional(), }), @@ -96,45 +118,67 @@ export const systemConfigInputSchema = z.object({ }).optional(), }).refine(obj => obj.pubsub || obj.kafka || obj.amqp, { message: 'A message broker implementation must be set' - }) - }), - server: z.object({ - logLevel: z.number().min(0).max(6).default(0).optional(), - host: z.string().default("localhost").optional(), - port: z.number().int().positive().default(8081).optional(), + }), swagger: z.object({ path: z.string().default('/docs').optional(), + logoPath: z.string(), exposeData: z.boolean().default(true).optional(), exposeMessage: z.boolean().default(true).optional(), }).optional(), + directus: z.object({ + host: z.string().default("localhost").optional(), + port: z.number().int().positive().default(8055).optional(), + token: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + generateFlows: z.boolean().default(false).optional(), + }).refine(obj => obj.generateFlows && !obj.host, { + message: 'Directus host must be set if generateFlows is true' + }).optional(), + networkConnection: z.object({ + websocketServers: z.array(websocketServerInputSchema.optional()) + }) }), - websocket: z.object({ - pingInterval: z.number().int().positive().default(60).optional(), - maxCallLengthSeconds: z.number().int().positive().default(5).optional(), - maxCachingSeconds: z.number().int().positive().default(10).optional() - }), - websocketSecurity: z.object({ - // TODO: Add support for each websocketServer/tenant to have its own certificates - // Such as when different tenants use different certificate roots for additional security - tlsKeysFilepath: z.string().optional(), - tlsCertificateChainFilepath: z.string().optional(), - mtlsCertificateAuthorityRootsFilepath: z.string().optional(), - mtlsCertificateAuthorityKeysFilepath: z.string().optional() - }).optional(), - websocketServer: z.array(z.object({ - // This allows multiple servers, ideally for different security profile levels - // TODO: Add support for tenant ids on server level for tenant-specific behavior - securityProfile: z.number().int().min(0).max(3).default(0).optional(), - port: z.number().int().positive().default(8080).optional(), - host: z.string().default('localhost').optional(), - protocol: z.string().default('ocpp2.0.1').optional(), - })) + logLevel: z.number().min(0).max(6).default(0).optional(), + maxCallLengthSeconds: z.number().int().positive().default(5).optional(), + maxCachingSeconds: z.number().int().positive().default(10).optional() }); export type SystemConfigInput = z.infer; +export const websocketServerSchema = z.object({ + // TODO: Add support for tenant ids on server level for tenant-specific behavior + id: z.string(), + host: z.string(), + port: z.number().int().positive(), + pingInterval: z.number().int().positive(), + protocol: z.string(), + securityProfile: z.number().int().min(0).max(3), + allowUnknownChargingStations: z.boolean(), + tlsKeysFilepath: z.string().optional(), + tlsCertificateChainFilepath: z.string().optional(), + mtlsCertificateAuthorityRootsFilepath: z.string().optional(), + mtlsCertificateAuthorityKeysFilepath: z.string().optional() +}).refine(obj => { + switch (obj.securityProfile) { + case 0: // No security + case 1: // Basic Auth + return true; + case 2: // Basic Auth + TLS + return obj.tlsKeysFilepath && obj.tlsCertificateChainFilepath; + case 3: // mTLS + return obj.mtlsCertificateAuthorityRootsFilepath && obj.mtlsCertificateAuthorityKeysFilepath; + default: + return false; + } +}); + export const systemConfigSchema = z.object({ env: z.enum(["development", "production"]), + centralSystem: z.object({ + host: z.string(), + port: z.number().int().positive() + }), modules: z.object({ certificates: z.object({ endpointPrefix: z.string(), @@ -155,7 +199,7 @@ export const systemConfigSchema = z.object({ /** * If false, only data endpoint can update boot status to accepted */ - autoAccept: z.boolean(), + autoAccept: z.boolean(), endpointPrefix: z.string(), host: z.string().optional(), port: z.number().int().positive().optional(), @@ -179,6 +223,11 @@ export const systemConfigSchema = z.object({ endpointPrefix: z.string(), host: z.string().optional(), port: z.number().int().positive().optional(), + costUpdatedInterval: z.number().int().positive().optional(), + sendCostUpdatedOnMeterValue: z.boolean().optional(), + }).refine(obj => !(obj.costUpdatedInterval && obj.sendCostUpdatedOnMeterValue) && (obj.costUpdatedInterval || obj.sendCostUpdatedOnMeterValue), { + message: 'Can only update cost based on the interval or in response to a transaction event /meter value' + + ' update. Not allowed to have both costUpdatedInterval and sendCostUpdatedOnMeterValue configured' }), // Transactions module is required }), data: z.object({ @@ -225,78 +274,41 @@ export const systemConfigSchema = z.object({ }).optional(), }).refine(obj => obj.pubsub || obj.kafka || obj.amqp, { message: 'A message broker implementation must be set' - }) - }), - server: z.object({ - logLevel: z.number().min(0).max(6), - host: z.string(), - port: z.number().int().positive(), + }), swagger: z.object({ path: z.string(), + logoPath: z.string(), exposeData: z.boolean(), exposeMessage: z.boolean(), }).optional(), + directus: z.object({ + host: z.string(), + port: z.number().int().positive(), + token: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + generateFlows: z.boolean() + }).optional(), + networkConnection: z.object({ + websocketServers: z.array(websocketServerSchema).refine(array => { + const idsSeen = new Set(); + return array.filter(obj => { + if (idsSeen.has(obj.id)) { + return false; + } else { + idsSeen.add(obj.id); + return true; + } + }); + }) + }) }), - websocket: z.object({ - pingInterval: z.number().int().positive(), - maxCallLengthSeconds: z.number().int().positive(), - maxCachingSeconds: z.number().int().positive() - }).refine(websocketServer => websocketServer.maxCachingSeconds >= websocketServer.maxCallLengthSeconds, { - message: 'maxCachingSeconds cannot be less than maxCallLengthSeconds' - }), - websocketSecurity: z.object({ - // TODO: Add support for each websocketServer/tenant to have its own certificates - // Such as when different tenants use different certificate roots for additional security - tlsKeysFilepath: z.string().optional(), - tlsCertificateChainFilepath: z.string().optional(), - mtlsCertificateAuthorityRootsFilepath: z.string().optional(), - mtlsCertificateAuthorityKeysFilepath: z.string().optional() - }).optional(), - websocketServer: z.array(z.object({ - // This allows multiple servers, ideally for different security profile levels - // TODO: Add support for tenant ids on server level for tenant-specific behavior - securityProfile: z.number().int().min(0).max(3), - port: z.number().int().positive(), - host: z.string(), - protocol: z.string(), - })).refine(websocketServers => checkForHostPortDuplicates(websocketServers), { - message: 'host and port must be unique' - }) -}).refine((data) => { - const wsSecurity = data.websocketSecurity; - - const requiresTls = data.websocketServer.some(server => server.securityProfile >= 2); - const tlsFieldsFilled = wsSecurity?.tlsKeysFilepath && wsSecurity?.tlsCertificateChainFilepath; - - const requiresMtls = data.websocketServer.some(server => server.securityProfile >= 3); - const mtlsFieldsFilled = wsSecurity?.mtlsCertificateAuthorityRootsFilepath && wsSecurity?.mtlsCertificateAuthorityKeysFilepath; - - if (requiresTls && !tlsFieldsFilled) { - return false; - } - - if (requiresMtls && !mtlsFieldsFilled) { - return false; - } - - return true; -}, { - message: "TLS and/or mTLS fields must be filled based on the security profile of the websocket server." + logLevel: z.number().min(0).max(6), + maxCallLengthSeconds: z.number().int().positive(), + maxCachingSeconds: z.number().int().positive() +}).refine(obj => obj.maxCachingSeconds >= obj.maxCallLengthSeconds, { + message: 'maxCachingSeconds cannot be less than maxCallLengthSeconds' }); -export type SystemConfig = z.infer; - -function checkForHostPortDuplicates(websocketServers: { port: number; host: string;}[]): unknown { - const uniqueCombinations = new Set(); - for (const item of websocketServers) { - const combo = `${item.host}:${item.port}`; - - if (uniqueCombinations.has(combo)) { - return false; // Duplicate found - } - - uniqueCombinations.add(combo); - } - - return true; -} \ No newline at end of file +export type WebsocketServerConfig = z.infer; +export type SystemConfig = z.infer; \ No newline at end of file diff --git a/00_Base/src/index.ts b/00_Base/src/index.ts index 10202d912..02b5b93db 100644 --- a/00_Base/src/index.ts +++ b/00_Base/src/index.ts @@ -7,10 +7,10 @@ export { AbstractModuleApi, AsDataEndpoint, AsMessageEndpoint, HttpMethod, IModuleApi } from './interfaces/api'; export { CacheNamespace, ICache } from './interfaces/cache/cache'; -export { AbstractCentralSystem, ClientConnection, ICentralSystem, IClientConnection, OcppError } from './interfaces/centralsystem'; -export { AbstractMessageHandler, AbstractMessageSender, EventGroup, HandlerProperties, IMessage, IMessageConfirmation, IMessageContext, IMessageHandler, IMessageRouter, IMessageSender, Message, MessageOrigin, MessageState, RetryMessageError } from './interfaces/messages'; +export { AbstractMessageRouter, IAuthenticator, IMessageRouter } from './interfaces/router'; +export { AbstractMessageHandler, AbstractMessageSender, EventGroup, HandlerProperties, IMessage, IMessageConfirmation, IMessageContext, IMessageHandler, IMessageSender, Message, MessageOrigin, MessageState, RetryMessageError } from './interfaces/messages'; export { AbstractModule, AsHandler, IModule } from './interfaces/modules'; -export { Call, CallAction, CallError, CallResult, ErrorCode, MessageTypeId } from './ocpp/rpc/message'; +export { Call, CallAction, CallError, CallResult, ErrorCode, MessageTypeId, OcppError } from './ocpp/rpc/message'; // Persistence Interfaces @@ -19,9 +19,9 @@ export * from "./ocpp/persistence"; // Configuration Types -export { BootConfig } from "./config/BootConfig"; +export { BootConfig, BOOT_STATUS } from "./config/BootConfig"; export { defineConfig } from "./config/defineConfig"; -export { SystemConfig } from "./config/types"; +export { SystemConfig, WebsocketServerConfig } from "./config/types"; // Utils @@ -58,6 +58,7 @@ import { GetInstalledCertificateIdsResponseSchema, GetLocalListVersionResponseSchema, GetLogResponseSchema, + GetMonitoringReportResponseSchema, GetReportResponseSchema, GetTransactionStatusResponseSchema, GetVariablesResponseSchema, @@ -67,10 +68,13 @@ import { MeterValuesRequestSchema, NotifyCustomerInformationRequestSchema, NotifyDisplayMessagesRequestSchema, + NotifyEVChargingNeedsRequestSchema, + NotifyEVChargingScheduleRequestSchema, NotifyEventRequestSchema, NotifyMonitoringReportRequestSchema, NotifyReportRequestSchema, PublishFirmwareStatusNotificationRequestSchema, + ReportChargingProfilesRequestSchema, RequestStartTransactionResponseSchema, RequestStopTransactionResponseSchema, ReservationStatusUpdateRequestSchema, @@ -113,10 +117,13 @@ export const CALL_SCHEMA_MAP: Map = new Map = new Map = new Map = new Map implements IModuleApi } as const }; - if (this._module.config.server.swagger?.exposeMessage) { + if (this._module.config.util.swagger?.exposeMessage) { this._server.register(async (fastifyInstance) => { fastifyInstance.post(this._toMessagePath(action), _opts, _handler); }); @@ -129,7 +129,7 @@ export abstract class AbstractModuleApi implements IModuleApi handler: _handler }; - if (this._module.config.server.swagger?.exposeData) { + if (this._module.config.util.swagger?.exposeData) { this._server.register(async (fastifyInstance) => { fastifyInstance.route<{ Body: object, Querystring: object }>(_opts); }); diff --git a/00_Base/src/interfaces/cache/cache.ts b/00_Base/src/interfaces/cache/cache.ts index 238d16b4b..1f5eee750 100644 --- a/00_Base/src/interfaces/cache/cache.ts +++ b/00_Base/src/interfaces/cache/cache.ts @@ -77,6 +77,7 @@ export interface ICache { * */ setIfNotExist(key: string, value: string, namespace?: string, expireSeconds?: number): Promise; + // TODO: Consider removing this method, no longer used /** * Sets a value synchronously in the underlying cache. * diff --git a/00_Base/src/interfaces/centralsystem.ts b/00_Base/src/interfaces/centralsystem.ts deleted file mode 100644 index 5d4683022..000000000 --- a/00_Base/src/interfaces/centralsystem.ts +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) 2023 S44, LLC -// Copyright Contributors to the CitrineOS Project -// -// SPDX-License-Identifier: Apache 2.0 - -import Ajv, { ErrorObject } from "ajv"; -import { ILogObj, Logger } from "tslog"; -import { CALL_RESULT_SCHEMA_MAP, CALL_SCHEMA_MAP } from ".."; -import { SystemConfig } from "../config/types"; -import { Call, CallAction, CallError, CallResult, ErrorCode, MessageTypeId } from "../ocpp/rpc/message"; -import { ICache } from "./cache/cache"; - -/** - * Custom error to handle OCPP errors better. - */ -export class OcppError extends Error { - - private _messageId: string; - private _errorCode: ErrorCode; - private _errorDetails: object; - - constructor(messageId: string, errorCode: ErrorCode, errorDescription: string, errorDetails: object = {}) { - super(errorDescription); - this.name = "OcppError"; - this._messageId = messageId; - this._errorCode = errorCode; - this._errorDetails = errorDetails; - } - - asCallError(): CallError { - return [MessageTypeId.CallError, this._messageId, this._errorCode, this.message, this._errorDetails] as CallError; - } -} - -/** - * Interface for the central system - */ -export interface ICentralSystem { - - onCall(connection: IClientConnection, message: Call): void; - onCallResult(connection: IClientConnection, message: CallResult): void; - onCallError(connection: IClientConnection, message: CallError): void; - - sendCall(identifier: string, message: Call): Promise; - sendCallResult(identifier: string, message: CallResult): Promise; - sendCallError(identifier: string, message: CallError): Promise; - - shutdown(): void; -} - -export abstract class AbstractCentralSystem implements ICentralSystem { - - /** - * Fields - */ - - protected _ajv: Ajv; - protected _cache?: ICache; - protected _config: SystemConfig; - protected _logger: Logger; - - /** - * Constructor of abstract central system. - * - * @param {Ajv} ajv - The Ajv instance to use for schema validation. - */ - constructor(config: SystemConfig, logger?: Logger, blacklistCache?: ICache, ajv?: Ajv) { - this._config = config; - this._cache = blacklistCache; - this._ajv = ajv || new Ajv({ removeAdditional: 'all', useDefaults: true, coerceTypes: 'array', strict: false }); - this._logger = logger ? logger.getSubLogger({ name: this.constructor.name }) : new Logger({ name: this.constructor.name }); - } - - abstract onCall(connection: IClientConnection, message: Call): void; - abstract onCallResult(connection: IClientConnection, message: CallResult): void; - abstract onCallError(connection: IClientConnection, message: CallError): void; - - abstract sendCall(identifier: string, message: Call): Promise; - abstract sendCallResult(identifier: string, message: CallResult): Promise; - abstract sendCallError(identifier: string, message: CallError): Promise; - - abstract shutdown(): void; - - /** - * Protected Methods - */ - - /** - * Validates a Call object against its schema. - * - * @param {string} identifier - The identifier of the EVSE. - * @param {Call} message - The Call object to validate. - * @return {boolean} - Returns true if the Call object is valid, false otherwise. - */ - protected _validateCall(identifier: string, message: Call): { isValid: boolean, errors?: ErrorObject[] | null } { - const action = message[2] as CallAction; - const payload = message[3]; - - const schema = CALL_SCHEMA_MAP.get(action); - if (schema) { - const validate = this._ajv.compile(schema); - const result = validate(payload); - if (!result) { - this._logger.debug('Validate Call failed', validate.errors); - return { isValid: false, errors: validate.errors }; - } else { - return { isValid: true }; - } - } else { - this._logger.error("No schema found for action", action, message); - return { isValid: false }; // TODO: Implement config for this behavior - } - } - - /** - * Validates a CallResult object against its schema. - * - * @param {string} identifier - The identifier of the EVSE. - * @param {CallAction} action - The original CallAction. - * @param {CallResult} message - The CallResult object to validate. - * @return {boolean} - Returns true if the CallResult object is valid, false otherwise. - */ - protected _validateCallResult(identifier: string, action: CallAction, message: CallResult): { isValid: boolean, errors?: ErrorObject[] | null } { - const payload = message[2]; - - const schema = CALL_RESULT_SCHEMA_MAP.get(action); - if (schema) { - const validate = this._ajv.compile(schema); - const result = validate(payload); - if (!result) { - this._logger.debug('Validate CallResult failed', validate.errors); - return { isValid: false, errors: validate.errors }; - } else { - return { isValid: true }; - } - } else { - this._logger.error("No schema found for call result with action", action, message); - return { isValid: false }; // TODO: Implement config for this behavior - } - } -} - -/** - * Interface for the client connection - */ -export interface IClientConnection { - get identifier(): string; - get sessionIndex(): string; - get ip(): string; - get port(): number; - get isAlive(): boolean; - set isAlive(value: boolean); -} - -/** - * Implementation of the client connection - */ -export class ClientConnection implements IClientConnection { - - /** - * Fields - */ - - private _identifier: string; - private _sessionIndex: string; - private _ip: string; - private _port: number; - private _isAlive: boolean; - - /** - * Constructor - */ - - constructor(identifier: string, sessionIndex: string, ip: string, port: number) { - this._identifier = identifier; - this._sessionIndex = sessionIndex; - this._ip = ip; - this._port = port; - this._isAlive = false; - } - - /** - * Properties - */ - - get identifier(): string { - return this._identifier; - } - - get sessionIndex(): string { - return this._sessionIndex; - } - - get ip(): string { - return this._ip; - } - - get port(): number { - return this._port; - } - - get isAlive(): boolean { - return this._isAlive; - } - - set isAlive(value: boolean) { - this._isAlive = value; - } - - get connectionUrl(): string { - return `ws://${this._ip}:${this._port}/${this._identifier}`; - } -} diff --git a/00_Base/src/interfaces/messages/AbstractMessageHandler.ts b/00_Base/src/interfaces/messages/AbstractMessageHandler.ts index 8b64a91b1..4d6b79d1d 100644 --- a/00_Base/src/interfaces/messages/AbstractMessageHandler.ts +++ b/00_Base/src/interfaces/messages/AbstractMessageHandler.ts @@ -20,6 +20,7 @@ export abstract class AbstractMessageHandler implements IMessageHandler { */ protected _config: SystemConfig; + protected _module?: IModule; protected _logger: Logger; /** @@ -28,20 +29,36 @@ export abstract class AbstractMessageHandler implements IMessageHandler { * @param config The system configuration. * @param logger [Optional] The logger to use. */ - constructor(config: SystemConfig, logger?: Logger) { + constructor(config: SystemConfig, logger?: Logger, module?: IModule) { this._config = config; + this._module = module; this._logger = logger ? logger.getSubLogger({ name: this.constructor.name }) : new Logger({ name: this.constructor.name }); } + /** + * Getter & Setter + */ + + get module(): IModule | undefined { + return this._module; + } + set module(value: IModule | undefined) { + this._module = value; + } + + /** + * Methods + */ + + async handle(message: IMessage, props?: HandlerProperties): Promise { + await this._module?.handle(message, props); + } + /** * Abstract Methods */ abstract subscribe(identifier: string, actions?: CallAction[], filter?: { [k: string]: string; }): Promise; abstract unsubscribe(identifier: string): Promise; - abstract handle(message: IMessage, props?: HandlerProperties): Promise; abstract shutdown(): void; - - abstract get module(): IModule | undefined; - abstract set module(value: IModule | undefined); } \ No newline at end of file diff --git a/00_Base/src/interfaces/messages/MessageHandler.ts b/00_Base/src/interfaces/messages/MessageHandler.ts index c6caf9052..f6b921c85 100644 --- a/00_Base/src/interfaces/messages/MessageHandler.ts +++ b/00_Base/src/interfaces/messages/MessageHandler.ts @@ -30,6 +30,7 @@ export interface IMessageHandler { * Unsubscribe from messages. E.g. when a connection drops. * * @param identifier - The identifier to unsubscribe from. + * @returns A promise that resolves to a boolean value indicating whether the unsubscription was successful. */ unsubscribe(identifier: string): Promise; diff --git a/00_Base/src/interfaces/messages/MessageRouter.ts b/00_Base/src/interfaces/messages/MessageRouter.ts deleted file mode 100644 index e65f36cdf..000000000 --- a/00_Base/src/interfaces/messages/MessageRouter.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2023 S44, LLC -// Copyright Contributors to the CitrineOS Project -// -// SPDX-License-Identifier: Apache 2.0 - -import { IMessageConfirmation, IMessageHandler, IMessageSender } from "../.."; -import { Call, CallAction, CallError, CallResult } from "../../ocpp/rpc/message"; -import { IClientConnection } from "../centralsystem"; - -/** - * MessageRouter - * - * The interface for all message routers. - */ -export interface IMessageRouter { - // API - registerConnection(client: IClientConnection): Promise; - // TODO: Add route for "unknown" messages - routeCall(client: IClientConnection, message: Call): Promise; - routeCallResult(client: IClientConnection, message: CallResult, action: CallAction): Promise; - routeCallError(client: IClientConnection, message: CallError, action: CallAction): Promise; - // Getter & Setter - get sender(): IMessageSender; - get handler(): IMessageHandler; -} \ No newline at end of file diff --git a/00_Base/src/interfaces/messages/index.ts b/00_Base/src/interfaces/messages/index.ts index f96dfba84..ff3b2ec84 100644 --- a/00_Base/src/interfaces/messages/index.ts +++ b/00_Base/src/interfaces/messages/index.ts @@ -23,6 +23,7 @@ export enum MessageOrigin { } export enum EventGroup { + All = 'all', General = 'general', Certificates = "certificates", Configuration = "configuration", @@ -33,10 +34,17 @@ export enum EventGroup { Transactions = 'transactions', } +export const eventGroupFromString = (source: string): EventGroup => { + const eventGroup: EventGroup = source as EventGroup; + if (!eventGroup) { + throw new Error(`Invalid event group soruce ${source}"`); + } + return eventGroup; +} + export { IMessage, Message } from "./Message"; export { IMessageHandler } from "./MessageHandler"; export { IMessageSender } from "./MessageSender"; -export { IMessageRouter } from "./MessageRouter"; export { IMessageContext } from "./MessageContext"; export { IMessageConfirmation } from "./MessageConfirmation"; export { AbstractMessageHandler } from "./AbstractMessageHandler"; diff --git a/00_Base/src/interfaces/modules/AbstractModule.ts b/00_Base/src/interfaces/modules/AbstractModule.ts index 87f02ef52..b73f21134 100644 --- a/00_Base/src/interfaces/modules/AbstractModule.ts +++ b/00_Base/src/interfaces/modules/AbstractModule.ts @@ -11,15 +11,14 @@ import { v4 as uuidv4 } from "uuid"; import { AS_HANDLER_METADATA, IHandlerDefinition, IModule } from "."; import { OcppRequest, OcppResponse } from "../.."; import { SystemConfig } from "../../config/types"; -import { CallAction, ErrorCode } from "../../ocpp/rpc/message"; +import { CallAction, ErrorCode, OcppError } from "../../ocpp/rpc/message"; import { RequestBuilder } from "../../util/request"; import { CacheNamespace, ICache } from "../cache/cache"; -import { ClientConnection, OcppError } from "../centralsystem"; import { EventGroup, HandlerProperties, IMessage, IMessageConfirmation, IMessageHandler, IMessageSender, MessageOrigin, MessageState } from "../messages"; export abstract class AbstractModule implements IModule { - public readonly CALLBACK_URL_CACHE_PREFIX: string = "CALLBACK_URL_"; + public static readonly CALLBACK_URL_CACHE_PREFIX: string = "CALLBACK_URL_"; protected _config: SystemConfig; protected readonly _cache: ICache; @@ -66,7 +65,7 @@ export abstract class AbstractModule implements IModule { this._config = config; // Update all necessary settings for hot reload this._logger.info(`Updating system configuration for ${this._eventGroup} module...`); - this._logger.settings.minLevel = this._config.server.logLevel; + this._logger.settings.minLevel = this._config.logLevel; } get config(): SystemConfig { @@ -85,6 +84,7 @@ export abstract class AbstractModule implements IModule { * @return {Promise} Returns a promise that resolves to a boolean indicating if the initialization was successful. */ protected async _initHandler(requests: CallAction[], responses: CallAction[]): Promise { + this._handler.module = this; let success = await this._handler.subscribe(this._eventGroup.toString() + "_requests", requests, { state: MessageState.Request.toString() @@ -105,7 +105,7 @@ export abstract class AbstractModule implements IModule { protected _initLogger(baseLogger?: Logger): Logger { return baseLogger ? baseLogger.getSubLogger({ name: this.constructor.name }) : new Logger({ name: this.constructor.name, - minLevel: this._config.server.logLevel, + minLevel: this._config.logLevel, hideLogPositionForProduction: this._config.env === "production" }); } @@ -133,7 +133,7 @@ export abstract class AbstractModule implements IModule { async handle(message: IMessage, props?: HandlerProperties): Promise { if (message.state === MessageState.Response) { this.handleMessageApiCallback(message as IMessage); - this._cache.set(message.context.correlationId, JSON.stringify(message.payload), message.context.stationId, this._config.websocket.maxCachingSeconds); + this._cache.set(message.context.correlationId, JSON.stringify(message.payload), message.context.stationId, this._config.maxCachingSeconds); } try { const handlerDefinition = (Reflect.getMetadata(AS_HANDLER_METADATA, this.constructor) as Array).filter((h) => h.action === message.action).pop(); @@ -153,7 +153,7 @@ export abstract class AbstractModule implements IModule { } async handleMessageApiCallback(message: IMessage): Promise { - const url: string | null = await this._cache.get(message.context.correlationId, this.CALLBACK_URL_CACHE_PREFIX + message.context.stationId); + const url: string | null = await this._cache.get(message.context.correlationId, AbstractModule.CALLBACK_URL_CACHE_PREFIX + message.context.stationId); if (url) { try { await fetch(url, { @@ -201,12 +201,12 @@ export abstract class AbstractModule implements IModule { const _correlationId: string = correlationId == undefined ? uuidv4() : correlationId; if (callbackUrl) { // TODO: Handle callErrors, failure to send to charger, timeout from charger, with different responses to callback - this._cache.set(_correlationId, callbackUrl, this.CALLBACK_URL_CACHE_PREFIX + identifier, - this._config.websocket.maxCachingSeconds); + this._cache.set(_correlationId, callbackUrl, AbstractModule.CALLBACK_URL_CACHE_PREFIX + identifier, + this._config.maxCachingSeconds); } // TODO: Future - Compound key with tenantId - return this._cache.get(identifier, CacheNamespace.Connections, () => ClientConnection).then((connection) => { - if (connection && connection.isAlive) { + return this._cache.get(identifier, CacheNamespace.Connections).then((connection) => { + if (connection) { return this._sender.sendRequest( RequestBuilder.buildCall( identifier, diff --git a/00_Base/src/interfaces/modules/Module.ts b/00_Base/src/interfaces/modules/Module.ts index a642bb47e..13493565f 100644 --- a/00_Base/src/interfaces/modules/Module.ts +++ b/00_Base/src/interfaces/modules/Module.ts @@ -15,10 +15,8 @@ export interface IModule { sendCall(identifier: string, tenantId: string, action: CallAction, payload: OcppRequest, correlationId?: string, origin?: MessageOrigin): Promise; sendCallResult(correlationId: string, identifier: string, tenantId: string, action: CallAction, payload: OcppResponse, origin?: MessageOrigin): Promise; - sendCallResultWithMessage(message: IMessage, payload: OcppResponse): Promise sendCallError(correlationId: string, identifier: string, tenantId: string, action: CallAction, error: OcppError, origin?: MessageOrigin): Promise; - sendCallErrorWithMessage(message: IMessage, error: OcppError): Promise; - + handle(message: IMessage, props?: HandlerProperties): Promise; shutdown(): void; diff --git a/00_Base/src/interfaces/router/AbstractRouter.ts b/00_Base/src/interfaces/router/AbstractRouter.ts new file mode 100644 index 000000000..dde4a694b --- /dev/null +++ b/00_Base/src/interfaces/router/AbstractRouter.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import Ajv, { ErrorObject } from "ajv"; + +import { Call, CallAction, CallResult, ICache, SystemConfig, CALL_SCHEMA_MAP, CALL_RESULT_SCHEMA_MAP, IMessageConfirmation, MessageOrigin, OcppError, OcppRequest, OcppResponse, IMessageHandler, IMessageSender, IMessage, MessageState } from "../.."; +import { ILogObj, Logger } from "tslog"; +import { IMessageRouter } from "./Router"; + +export abstract class AbstractMessageRouter implements IMessageRouter { + + /** + * Fields + */ + + protected _ajv: Ajv; + protected _cache: ICache; + protected _config: SystemConfig; + protected _logger: Logger; + protected readonly _handler: IMessageHandler; + protected readonly _sender: IMessageSender; + protected _networkHook: (identifier: string, message: string) => Promise; + + /** + * Constructor of abstract ocpp router. + * + * @param {Ajv} ajv - The Ajv instance to use for schema validation. + */ + constructor(config: SystemConfig, cache: ICache, handler: IMessageHandler, sender: IMessageSender, + networkHook: (identifier: string, message: string) => Promise, logger?: Logger, ajv?: Ajv) { + this._config = config; + this._cache = cache; + this._handler = handler; + this._sender = sender; + this._networkHook = networkHook; + this._ajv = ajv || new Ajv({ removeAdditional: 'all', useDefaults: true, coerceTypes: 'array', strict: false }); + this._logger = logger ? logger.getSubLogger({ name: this.constructor.name }) : new Logger({ name: this.constructor.name }); + + // Set module for proper message flow. + this._handler.module = this; + } + + /** + * Getters & Setters + */ + + get cache(): ICache { + return this._cache; + } + + get sender(): IMessageSender { + return this._sender; + } + + get handler(): IMessageHandler { + return this._handler; + } + + set networkHook(value: (identifier: string, message: string) => Promise) { + this._networkHook = value; + } + + /** + * Sets the system configuration for the module. + * + * @param {SystemConfig} config - The new configuration to set. + */ + set config(config: SystemConfig) { + this._config = config; + // Update all necessary settings for hot reload + this._logger.info(`Updating system configuration for ocpp router...`); + this._logger.settings.minLevel = this._config.logLevel; + } + + get config(): SystemConfig { + return this._config; + } + + abstract onMessage(identifier: string, message: string): Promise; + + abstract registerConnection(connectionIdentifier: string): Promise; + abstract deregisterConnection(connectionIdentifier: string): Promise; + + abstract sendCall(identifier: string, tenantId: string, action: CallAction, payload: OcppRequest, correlationId?: string, origin?: MessageOrigin): Promise; + abstract sendCallResult(correlationId: string, identifier: string, tenantId: string, action: CallAction, payload: OcppResponse, origin?: MessageOrigin): Promise; + abstract sendCallError(correlationId: string, identifier: string, tenantId: string, action: CallAction, error: OcppError, origin?: MessageOrigin): Promise; + + abstract shutdown(): void; + + /** + * Public Methods + */ + + async handle(message: IMessage): Promise { + this._logger.debug("Received message:", message); + + if (message.state === MessageState.Response) { + if (message.payload instanceof OcppError) { + await this.sendCallError(message.context.correlationId, message.context.stationId, message.context.tenantId, message.action, message.payload, message.origin); + } else { + await this.sendCallResult(message.context.correlationId, message.context.stationId, message.context.tenantId, message.action, message.payload, message.origin); + } + } else if (message.state === MessageState.Request) { + await this.sendCall(message.context.stationId, message.context.tenantId, message.action, message.payload, message.context.correlationId, message.origin); + } + } + + /** + * Protected Methods + */ + + /** + * Validates a Call object against its schema. + * + * @param {string} identifier - The identifier of the EVSE. + * @param {Call} message - The Call object to validate. + * @return {boolean} - Returns true if the Call object is valid, false otherwise. + */ + protected _validateCall(identifier: string, message: Call): { isValid: boolean, errors?: ErrorObject[] | null } { + const action = message[2] as CallAction; + const payload = message[3]; + + const schema = CALL_SCHEMA_MAP.get(action); + if (schema) { + const validate = this._ajv.compile(schema); + const result = validate(payload); + if (!result) { + this._logger.debug('Validate Call failed', validate.errors); + return { isValid: false, errors: validate.errors }; + } else { + return { isValid: true }; + } + } else { + this._logger.error("No schema found for action", action, message); + return { isValid: false }; // TODO: Implement config for this behavior + } + } + + /** + * Validates a CallResult object against its schema. + * + * @param {string} identifier - The identifier of the EVSE. + * @param {CallAction} action - The original CallAction. + * @param {CallResult} message - The CallResult object to validate. + * @return {boolean} - Returns true if the CallResult object is valid, false otherwise. + */ + protected _validateCallResult(identifier: string, action: CallAction, message: CallResult): { isValid: boolean, errors?: ErrorObject[] | null } { + const payload = message[2]; + + const schema = CALL_RESULT_SCHEMA_MAP.get(action); + if (schema) { + const validate = this._ajv.compile(schema); + const result = validate(payload); + if (!result) { + this._logger.debug('Validate CallResult failed', validate.errors); + return { isValid: false, errors: validate.errors }; + } else { + return { isValid: true }; + } + } else { + this._logger.error("No schema found for call result with action", action, message); + return { isValid: false }; // TODO: Implement config for this behavior + } + } +} diff --git a/00_Base/src/interfaces/router/Authenticator.ts b/00_Base/src/interfaces/router/Authenticator.ts new file mode 100644 index 000000000..319ff9efa --- /dev/null +++ b/00_Base/src/interfaces/router/Authenticator.ts @@ -0,0 +1,7 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +export interface IAuthenticator { + authenticate(allowUnknownChargingStations: boolean, identifier: string, username?: string, password?: string): Promise; +} \ No newline at end of file diff --git a/00_Base/src/interfaces/router/Router.ts b/00_Base/src/interfaces/router/Router.ts new file mode 100644 index 000000000..255a93a0b --- /dev/null +++ b/00_Base/src/interfaces/router/Router.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { IModule } from "../.."; + +/** + * Interface for the ocpp router + */ +export interface IMessageRouter extends IModule { + /** + * Register a connection to the message handler with the given connection identifier. + * + * @param {string} connectionIdentifier - the identifier of the connection + * @return {Promise} true if both request and response subscriptions are successful, false otherwise + */ + registerConnection(connectionIdentifier: string): Promise + deregisterConnection(connectionIdentifier: string): Promise + + onMessage(identifier: string, message: string): Promise; + + networkHook: (identifier: string, message: string) => Promise; +} \ No newline at end of file diff --git a/00_Base/src/interfaces/router/index.ts b/00_Base/src/interfaces/router/index.ts new file mode 100644 index 000000000..e8751ee8b --- /dev/null +++ b/00_Base/src/interfaces/router/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +export { IMessageRouter } from "./Router"; +export { AbstractMessageRouter } from "./AbstractRouter"; +export { IAuthenticator } from "./Authenticator" \ No newline at end of file diff --git a/00_Base/src/ocpp/persistence/namespace.ts b/00_Base/src/ocpp/persistence/namespace.ts index 4e742244f..144acd5a1 100644 --- a/00_Base/src/ocpp/persistence/namespace.ts +++ b/00_Base/src/ocpp/persistence/namespace.ts @@ -4,26 +4,33 @@ // SPDX-License-Identifier: Apache 2.0 /** - * Persisted Datatypes and their namespaces + * Persisted DataTypes and their namespaces */ export enum Namespace { AdditionalInfoType = 'AdditionalInfo', AuthorizationData = 'Authorization', AuthorizationRestrictions = 'AuthorizationRestrictions', BootConfig = 'Boot', - ChargingStationType = 'ChargingStation', + ChargingStation = 'ChargingStation', ComponentType = 'Component', - EVSEType = 'EVSE', + EVSEType = 'Evse', + EventDataType = 'EventData', IdTokenInfoType = 'IdTokenInfo', IdTokenType = 'IdToken', + Location = 'Location', MeterValueType = 'MeterValue', ModemType = 'Modem', + MessageInfoType = 'MessageInfo', SecurityEventNotificationRequest = 'SecurityEvent', + Subscription = 'Subscription', + SystemConfig = 'SystemConfig', TransactionEventRequest = 'TransactionEvent', TransactionType = 'Transaction', + Tariff = 'Tariff', VariableAttributeType = 'VariableAttribute', VariableCharacteristicsType = 'VariableCharacteristics', + VariableMonitoringType = 'VariableMonitoring', + VariableMonitoringStatus = 'VariableMonitoringStatus', VariableStatus = 'VariableStatus', - VariableType = 'Variable', - SystemConfig = 'SystemConfig' + VariableType = 'Variable' } \ No newline at end of file diff --git a/00_Base/src/ocpp/rpc/message.ts b/00_Base/src/ocpp/rpc/message.ts index 527209c89..782c0a97d 100644 --- a/00_Base/src/ocpp/rpc/message.ts +++ b/00_Base/src/ocpp/rpc/message.ts @@ -47,7 +47,7 @@ export enum CallAction { ClearDisplayMessage = 'ClearDisplayMessage', ClearedChargingLimit = 'ClearedChargingLimit', ClearVariableMonitoring = 'ClearVariableMonitoring', - CostUpdate = 'CostUpdate', + CostUpdated = 'CostUpdated', CustomerInformation = 'CustomerInformation', DataTransfer = 'DataTransfer', DeleteCertificate = 'DeleteCertificate', @@ -121,3 +121,25 @@ export enum ErrorCode { SecurityError = 'SecurityError', // During the processing of Action a security issue occurred preventing receiver from completing the Action successfully TypeConstraintViolation = 'TypeConstraintViolation', // Payload for Action is syntactically correct but at least one of the fields violates data type constraints (e.g. 'somestring': 12) } + +/** + * Custom error to handle OCPP errors better. + */ +export class OcppError extends Error { + + private _messageId: string; + private _errorCode: ErrorCode; + private _errorDetails: object; + + constructor(messageId: string, errorCode: ErrorCode, errorDescription: string, errorDetails: object = {}) { + super(errorDescription); + this.name = "OcppError"; + this._messageId = messageId; + this._errorCode = errorCode; + this._errorDetails = errorDetails; + } + + asCallError(): CallError { + return [MessageTypeId.CallError, this._messageId, this._errorCode, this.message, this._errorDetails] as CallError; + } +} diff --git a/00_Base/tsconfig.json b/00_Base/tsconfig.json index 5cebe347b..098d9daf0 100644 --- a/00_Base/tsconfig.json +++ b/00_Base/tsconfig.json @@ -1,21 +1,12 @@ { - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": true, - "outDir": "lib", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, + "extends": "../tsconfig.build.json", "include": [ - "src" + "src/**/*.ts", + "src/**/*.json" ], - "exclude": [ - "node_modules", - "**/__tests__/*" - ] + "compilerOptions": { + "outDir": "./dist/", + "composite": true, + "rootDir": "./src" + } } \ No newline at end of file diff --git a/01_Data/package.json b/01_Data/package.json index 0b0efbdf7..a8565a273 100644 --- a/01_Data/package.json +++ b/01_Data/package.json @@ -2,17 +2,16 @@ "name": "@citrineos/data", "version": "1.0.0", "description": "The OCPP data module which includes all persistence layer implementation.", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "lib" + "dist" ], "scripts": { - "install-base": "cd ../00_Base && npm run build && npm pack && cd ../01_Data && npm install ../00_Base/citrineos-base-1.0.0.tgz", "prepublish": "npx eslint", - "prepare": "npm run build", - "build": "tsc", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "compile": "npm run clean && tsc -p tsconfig.json", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo" }, "keywords": [ "ocpp", @@ -27,7 +26,8 @@ "typescript": "^5.0.4" }, "dependencies": { - "@citrineos/base": "file:../00_Base/citrineos-base-1.0.0.tgz", + "@citrineos/base": "1.0.0", + "@types/sequelize": "^4.28.20", "bcrypt": "^5.1.1", "pg": "^8.11.3", "pg-hstore": "^2.3.4", @@ -37,5 +37,6 @@ }, "engines": { "node": ">=18" - } + }, + "workspace": "../" } diff --git a/01_Data/src/index.ts b/01_Data/src/index.ts index 2762af0de..9d5affee8 100644 --- a/01_Data/src/index.ts +++ b/01_Data/src/index.ts @@ -4,4 +4,15 @@ // SPDX-License-Identifier: Apache 2.0 export * as sequelize from "./layers/sequelize"; -export * from "./interfaces"; \ No newline at end of file +export * from "./interfaces"; +export { + Boot, + Component, + DeviceModelRepository, + MeterValue, + Subscription, + Tariff, + Transaction, + Variable, + VariableAttribute +} from "./layers/sequelize"; // todo export better as these seem to be used in other modules \ No newline at end of file diff --git a/01_Data/src/interfaces/index.ts b/01_Data/src/interfaces/index.ts index a955b6c03..0bc8ca4a3 100644 --- a/01_Data/src/interfaces/index.ts +++ b/01_Data/src/interfaces/index.ts @@ -10,7 +10,10 @@ export { ChargingStationKeyQuerystring, ChargingStationKeyQuerySchema } from "./ export { VariableAttributeQuerystring, VariableAttributeQuerySchema, CreateOrUpdateVariableAttributeQuerystring, CreateOrUpdateVariableAttributeQuerySchema } from "./queries/VariableAttribute"; export { AuthorizationQuerystring, AuthorizationQuerySchema } from "./queries/Authorization"; export { TransactionEventQuerystring, TransactionEventQuerySchema } from "./queries/TransactionEvent"; +export { TariffQueryString, TariffQuerySchema, CreateOrUpdateTariffQuerySchema, CreateOrUpdateTariffQueryString } from "./queries/Tariff"; +export { ModelKeyQuerystring, ModelKeyQuerystringSchema } from "./queries/Model"; // Data projection models export { AuthorizationRestrictions } from "./projections/AuthorizationRestrictions"; -export { default as AuthorizationRestrictionsSchema } from './projections/schemas/AuthorizationRestrictionsSchema.json' \ No newline at end of file +export { default as AuthorizationRestrictionsSchema } from './projections/schemas/AuthorizationRestrictionsSchema.json' +export { default as TariffSchema } from './projections/schemas/TariffSchema.json' \ No newline at end of file diff --git a/01_Data/src/interfaces/projections/schemas/TariffSchema.json b/01_Data/src/interfaces/projections/schemas/TariffSchema.json new file mode 100644 index 000000000..ea812db92 --- /dev/null +++ b/01_Data/src/interfaces/projections/schemas/TariffSchema.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "stationId": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "price": { + "type": "number" + } + }, + "required": [ + "stationId", + "unit", + "price" + ] +} \ No newline at end of file diff --git a/01_Data/src/interfaces/queries/Model.ts b/01_Data/src/interfaces/queries/Model.ts new file mode 100644 index 000000000..262a1ee2d --- /dev/null +++ b/01_Data/src/interfaces/queries/Model.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { QuerySchema } from "@citrineos/base"; + +export interface ModelKeyQuerystring { + id: number +} + +export const ModelKeyQuerystringSchema = QuerySchema([["id", "number"]], ["id"]); \ No newline at end of file diff --git a/01_Data/src/interfaces/queries/Tariff.ts b/01_Data/src/interfaces/queries/Tariff.ts new file mode 100644 index 000000000..46967ebda --- /dev/null +++ b/01_Data/src/interfaces/queries/Tariff.ts @@ -0,0 +1,21 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { QuerySchema } from "@citrineos/base"; + +export const TariffQuerySchema = QuerySchema([ + ["stationId", "string"], ["unit", "string"], ["id", "number"]]); + +export interface TariffQueryString { + stationId?: string, + unit?: string, + id?: number +} + +export const CreateOrUpdateTariffQuerySchema = QuerySchema([ + ["stationId", "string"]], ["stationId"]); + +export interface CreateOrUpdateTariffQueryString { + stationId: string +} \ No newline at end of file diff --git a/01_Data/src/interfaces/repositories.ts b/01_Data/src/interfaces/repositories.ts index 378506f37..ca512a44f 100644 --- a/01_Data/src/interfaces/repositories.ts +++ b/01_Data/src/interfaces/repositories.ts @@ -3,12 +3,38 @@ // // SPDX-License-Identifier: Apache 2.0 -import { SetVariableDataType, ICrudRepository, SetVariableResultType, AuthorizationData, TransactionEventRequest, ChargingStateEnumType, IdTokenType, VariableAttributeType, ReportDataType, BootConfig, RegistrationStatusEnumType, StatusInfoType, GetVariableResultType, EVSEType, SecurityEventNotificationRequest } from "@citrineos/base"; +import { + SetVariableDataType, + ICrudRepository, + SetVariableResultType, + AuthorizationData, + TransactionEventRequest, + ChargingStateEnumType, + IdTokenType, + VariableAttributeType, + ReportDataType, + BootConfig, + RegistrationStatusEnumType, + StatusInfoType, + GetVariableResultType, + EVSEType, + SecurityEventNotificationRequest, + VariableType, + ComponentType, + MonitoringDataType, + VariableMonitoringType, + SetMonitoringDataType, + SetMonitoringResultType, + EventDataType, + CallAction, + MessageInfoType +} from "@citrineos/base"; import { AuthorizationQuerystring } from "./queries/Authorization"; import { Transaction } from "../layers/sequelize/model/TransactionEvent"; import { VariableAttribute } from "../layers/sequelize/model/DeviceModel/VariableAttribute"; -import { AuthorizationRestrictions, VariableAttributeQuerystring } from "."; -import { Boot, Authorization, SecurityEvent } from "../layers/sequelize"; +import { AuthorizationRestrictions, VariableAttributeQuerystring, TariffQueryString } from "."; +import { Boot, Authorization, Location, SecurityEvent, Component, Variable, VariableMonitoring, EventData, ChargingStation, MessageInfo, Tariff, MeterValue } from "../layers/sequelize"; +import { Subscription } from "../layers/sequelize/model/Subscription/Subscription"; export interface IAuthorizationRepository extends ICrudRepository { @@ -40,6 +66,12 @@ export interface IDeviceModelRepository extends ICrudRepository; existsByQuery(query: VariableAttributeQuerystring): Promise; deleteAllByQuery(query: VariableAttributeQuerystring): Promise; + findComponentAndVariable(componentType: ComponentType, variableType: VariableType): Promise<[Component | null, Variable | null]> + findOrCreateEvseAndComponent(componentType: ComponentType, stationId: string): Promise +} + +export interface ILocationRepository extends ICrudRepository { + readChargingStationByStationId(stationId: string): Promise } export interface ISecurityEventRepository extends ICrudRepository { @@ -48,10 +80,38 @@ export interface ISecurityEventRepository extends ICrudRepository deleteByKey(key: string): Promise; } +export interface ISubscriptionRepository extends ICrudRepository { + create(value: Subscription): Promise; + readAllByStationId(stationId: string): Promise + deleteByKey(key: string): Promise; +} + export interface ITransactionEventRepository extends ICrudRepository { createOrUpdateTransactionByTransactionEventAndStationId(value: TransactionEventRequest, stationId: string): Promise; readAllByStationIdAndTransactionId(stationId: string, transactionId: string): Promise; readTransactionByStationIdAndTransactionId(stationId: string, transactionId: string): Promise; readAllTransactionsByStationIdAndEvseAndChargingStates(stationId: string, evse: EVSEType, chargingStates?: ChargingStateEnumType[]): Promise; - readAllActiveTransactionByIdToken(idToken: IdTokenType): Promise; + readAllActiveTransactionsByIdToken(idToken: IdTokenType): Promise; + readAllMeterValuesByTransactionDataBaseId(transactionDataBaseId: number): Promise; +} + +export interface IVariableMonitoringRepository extends ICrudRepository { + createOrUpdateByMonitoringDataTypeAndStationId(value: MonitoringDataType, componentId: string, variableId: string, stationId: string): Promise; + createOrUpdateBySetMonitoringDataTypeAndStationId(value: SetMonitoringDataType, componentId: string, variableId: string, stationId: string): Promise; + rejectAllVariableMonitoringsByStationId(action: CallAction, stationId: string): Promise; + rejectVariableMonitoringByIdAndStationId(action: CallAction, id: number, stationId: string): Promise + updateResultByStationId(result: SetMonitoringResultType, stationId: string): Promise + createEventDatumByComponentIdAndVariableIdAndStationId(event: EventDataType, componentId: string, variableId: string, stationId: string): Promise +} + +export interface IMessageInfoRepository extends ICrudRepository { + deactivateAllByStationId(stationId: string): Promise; + createOrUpdateByMessageInfoTypeAndStationId(value: MessageInfoType, stationId: string, componentId?: number): Promise; +} + +export interface ITariffRepository extends ICrudRepository { + findByStationId(stationId: string): Promise; + readAllByQuery(query: TariffQueryString): Promise; + deleteAllByQuery(query: TariffQueryString): Promise; + createOrUpdateTariff(tariff: Tariff): Promise; } \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/index.ts b/01_Data/src/layers/sequelize/index.ts index 04dab4f7d..47f867d7b 100644 --- a/01_Data/src/layers/sequelize/index.ts +++ b/01_Data/src/layers/sequelize/index.ts @@ -1,4 +1,3 @@ -// Copyright (c) 2023 S44, LLC // Copyright Contributors to the CitrineOS Project // // SPDX-License-Identifier: Apache 2.0 @@ -7,16 +6,26 @@ export { Boot } from "./model/Boot"; export { VariableAttribute, VariableCharacteristics, Component, Evse, Variable } from "./model/DeviceModel"; export { Authorization, IdToken, IdTokenInfo, AdditionalInfo } from "./model/Authorization"; -export { Transaction } from "./model/TransactionEvent"; +export { Transaction, TransactionEvent, MeterValue } from "./model/TransactionEvent"; export { SecurityEvent } from "./model/SecurityEvent"; +export { VariableMonitoring, EventData, VariableMonitoringStatus } from "./model/VariableMonitoring"; +export { ChargingStation, Location } from "./model/Location"; +export { MessageInfo } from "./model/MessageInfo"; +export { Tariff } from "./model/Tariff/Tariffs"; +export { Subscription } from "./model/Subscription"; // Sequelize Repositories export { SequelizeRepository } from "./repository/Base"; export { AuthorizationRepository } from "./repository/Authorization"; export { BootRepository } from "./repository/Boot"; export { DeviceModelRepository } from "./repository/DeviceModel"; +export { LocationRepository } from "./repository/Location"; export { TransactionEventRepository } from "./repository/TransactionEvent"; export { SecurityEventRepository } from "./repository/SecurityEvent"; +export { VariableMonitoringRepository } from "./repository/VariableMonitoring"; +export { MessageInfoRepository } from "./repository/MessageInfo"; +export { TariffRepository } from "./repository/Tariff"; +export { SubscriptionRepository } from "./repository/Subscription"; // Sequelize Utilities export { DefaultSequelizeInstance } from "./util"; \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/DeviceModel/Component.ts b/01_Data/src/layers/sequelize/model/DeviceModel/Component.ts index 1cda963ae..d7a7e4285 100644 --- a/01_Data/src/layers/sequelize/model/DeviceModel/Component.ts +++ b/01_Data/src/layers/sequelize/model/DeviceModel/Component.ts @@ -4,9 +4,10 @@ // SPDX-License-Identifier: Apache 2.0 import { ComponentType, CustomDataType, EVSEType, Namespace, VariableType } from "@citrineos/base"; -import { BelongsTo, Column, DataType, ForeignKey, HasMany, Model, Table } from "sequelize-typescript"; +import { BelongsTo, BelongsToMany, Column, DataType, ForeignKey, Model, Table } from "sequelize-typescript"; import { Evse } from "./Evse"; import { Variable } from "./Variable"; +import { ComponentVariable } from "./ComponentVariable"; @Table export class Component extends Model implements ComponentType { @@ -21,30 +22,31 @@ export class Component extends Model implements ComponentType { @Column({ type: DataType.STRING, - unique: 'evse_name_instance' + unique: 'name_instance' }) declare name: string; - + @Column({ type: DataType.STRING, - unique: 'evse_name_instance' + unique: 'name_instance' }) declare instance?: string; /** * Relations */ - + @BelongsTo(() => Evse) declare evse?: EVSEType; @ForeignKey(() => Evse) - @Column({ - type: DataType.INTEGER, - unique: 'evse_name_instance' - }) + @Column(DataType.INTEGER) declare evseDatabaseId?: number; - @HasMany(() => Variable) + @BelongsToMany(() => Variable, () => ComponentVariable) declare variables?: VariableType[]; + + // Declare the association methods, to be automatically generated by Sequelize at runtime + public addVariable!: (variable: Variable) => Promise; + public getVariables!: () => Promise; } \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/DeviceModel/ComponentVariable.ts b/01_Data/src/layers/sequelize/model/DeviceModel/ComponentVariable.ts new file mode 100644 index 000000000..bcfbb849b --- /dev/null +++ b/01_Data/src/layers/sequelize/model/DeviceModel/ComponentVariable.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { Table, Model, ForeignKey, Column, DataType } from "sequelize-typescript"; +import { Component } from "./Component"; +import { Variable } from "./Variable"; + +@Table +export class ComponentVariable extends Model { + @ForeignKey(() => Component) + @Column(DataType.INTEGER) + declare componentId: number; + + @ForeignKey(() => Variable) + @Column(DataType.INTEGER) + declare variableId: number; +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/DeviceModel/Variable.ts b/01_Data/src/layers/sequelize/model/DeviceModel/Variable.ts index 6b93e4de2..d96a66b1b 100644 --- a/01_Data/src/layers/sequelize/model/DeviceModel/Variable.ts +++ b/01_Data/src/layers/sequelize/model/DeviceModel/Variable.ts @@ -4,10 +4,11 @@ // SPDX-License-Identifier: Apache 2.0 import { ComponentType, CustomDataType, Namespace, VariableCharacteristicsType, VariableType } from "@citrineos/base"; -import { BelongsTo, Column, DataType, ForeignKey, HasMany, HasOne, Model, Table } from "sequelize-typescript"; +import { BelongsToMany, Column, DataType, HasMany, HasOne, Model, Table } from "sequelize-typescript"; import { Component } from "./Component"; import { VariableAttribute } from "./VariableAttribute"; import { VariableCharacteristics } from "./VariableCharacteristics"; +import { ComponentVariable } from "./ComponentVariable"; @Table export class Variable extends Model implements VariableType { @@ -16,16 +17,16 @@ export class Variable extends Model implements VariableType { declare customData?: CustomDataType; - /** - * Fields - */ + /** + * Fields + */ @Column({ type: DataType.STRING, unique: 'name_instance' }) declare name: string; - + @Column({ type: DataType.STRING, unique: 'name_instance' @@ -35,17 +36,17 @@ export class Variable extends Model implements VariableType { /** * Relations */ - - @BelongsTo(() => Component) - declare component: ComponentType; - @ForeignKey(() => Component) - @Column(DataType.INTEGER) - declare componentId?: number; + @BelongsToMany(() => Component, () => ComponentVariable) + declare components?: ComponentType[]; @HasMany(() => VariableAttribute) declare variableAttributes?: VariableAttribute[]; @HasOne(() => VariableCharacteristics) - declare variableCharacteristics: VariableCharacteristicsType; + declare variableCharacteristics?: VariableCharacteristicsType; + + // Declare the association methods, to be automatically generated by Sequelize at runtime + public addComponent!: (variable: Component) => Promise; + public getComponents!: () => Promise; } \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/DeviceModel/VariableAttribute.ts b/01_Data/src/layers/sequelize/model/DeviceModel/VariableAttribute.ts index d384e52f8..08e20ed50 100644 --- a/01_Data/src/layers/sequelize/model/DeviceModel/VariableAttribute.ts +++ b/01_Data/src/layers/sequelize/model/DeviceModel/VariableAttribute.ts @@ -3,15 +3,14 @@ // // SPDX-License-Identifier: Apache 2.0 -import { AttributeEnumType, ComponentType, CustomDataType, DataEnumType, EVSEType, MutabilityEnumType, Namespace, StatusInfoType, VariableAttributeType, VariableType } from "@citrineos/base"; -import { BeforeCreate, BeforeUpdate, BelongsTo, Column, DataType, ForeignKey, HasMany, HasOne, Index, Model, Table } from "sequelize-typescript"; +import { AttributeEnumType, ComponentType, CustomDataType, DataEnumType, EVSEType, MutabilityEnumType, Namespace, VariableAttributeType, VariableType } from "@citrineos/base"; +import { BelongsTo, Column, DataType, ForeignKey, HasMany, Index, Model, Table } from "sequelize-typescript"; import * as bcrypt from "bcrypt"; import { Variable } from "./Variable"; import { Component } from "./Component"; import { Evse } from "./Evse"; import { Boot } from "../Boot"; import { VariableStatus } from "./VariableStatus"; -import { VariableCharacteristics } from "./VariableCharacteristics"; @Table export class VariableAttribute extends Model implements VariableAttributeType { @@ -26,14 +25,14 @@ export class VariableAttribute extends Model implements VariableAttributeType { @Index @Column({ - unique: 'stationId_type_variableId_componentId_evseDatabaseId' + unique: 'stationId_type_variableId_componentId' }) declare stationId: string; @Column({ type: DataType.STRING, defaultValue: AttributeEnumType.Actual, - unique: 'stationId_type_variableId_componentId_evseDatabaseId' + unique: 'stationId_type_variableId_componentId' }) declare type?: AttributeEnumType; // From VariableCharacteristics, which belongs to Variable associated with this VariableAttribute @@ -44,7 +43,7 @@ export class VariableAttribute extends Model implements VariableAttributeType { declare dataType: DataEnumType; @Column({ - // TODO: Make this configurable? + // TODO: Make this configurable? also used in VariableStatus model type: DataType.STRING(4000), set(valueString) { if (valueString) { @@ -91,7 +90,7 @@ export class VariableAttribute extends Model implements VariableAttributeType { @ForeignKey(() => Variable) @Column({ type: DataType.INTEGER, - unique: 'stationId_type_variableId_componentId_evseDatabaseId' + unique: 'stationId_type_variableId_componentId' }) declare variableId?: number; @@ -101,7 +100,7 @@ export class VariableAttribute extends Model implements VariableAttributeType { @ForeignKey(() => Component) @Column({ type: DataType.INTEGER, - unique: 'stationId_type_variableId_componentId_evseDatabaseId' + unique: 'stationId_type_variableId_componentId' }) declare componentId?: number; @@ -109,10 +108,7 @@ export class VariableAttribute extends Model implements VariableAttributeType { declare evse?: EVSEType; @ForeignKey(() => Evse) - @Column({ - type: DataType.INTEGER, - unique: 'stationId_type_variableId_componentId_evseDatabaseId' - }) + @Column(DataType.INTEGER) declare evseDatabaseId?: number; // History of variable status. Can be directly from GetVariablesResponse or SetVariablesResponse, or from NotifyReport handling, or from 'setOnCharger' option for data api diff --git a/01_Data/src/layers/sequelize/model/DeviceModel/VariableCharacteristics.ts b/01_Data/src/layers/sequelize/model/DeviceModel/VariableCharacteristics.ts index 4d132c31d..1780d1123 100644 --- a/01_Data/src/layers/sequelize/model/DeviceModel/VariableCharacteristics.ts +++ b/01_Data/src/layers/sequelize/model/DeviceModel/VariableCharacteristics.ts @@ -24,13 +24,13 @@ export class VariableCharacteristics extends Model implements VariableCharacteri @Column(DataType.STRING) declare dataType: DataEnumType; - @Column(DataType.INTEGER) + @Column(DataType.DECIMAL) declare minLimit?: number; - @Column(DataType.INTEGER) + @Column(DataType.DECIMAL) declare maxLimit?: number; - @Column(DataType.STRING) + @Column(DataType.STRING(4000)) declare valuesList?: string; @Column diff --git a/01_Data/src/layers/sequelize/model/DeviceModel/VariableStatus.ts b/01_Data/src/layers/sequelize/model/DeviceModel/VariableStatus.ts index 11e0bd43d..ff0cb21a7 100644 --- a/01_Data/src/layers/sequelize/model/DeviceModel/VariableStatus.ts +++ b/01_Data/src/layers/sequelize/model/DeviceModel/VariableStatus.ts @@ -2,10 +2,9 @@ // // SPDX-License-Identifier: Apache 2.0 -import { Namespace, CustomDataType } from "@citrineos/base"; -import { StatusInfoType } from "@citrineos/base/lib/ocpp/model/types/SetVariablesResponse"; -import { Table, Model, BelongsTo, Column, DataType, ForeignKey } from "sequelize-typescript"; -import { VariableAttribute } from "./VariableAttribute"; +import {CustomDataType, Namespace, StatusInfoType} from "@citrineos/base"; +import {BelongsTo, Column, DataType, ForeignKey, Model, Table} from "sequelize-typescript"; +import {VariableAttribute} from "./VariableAttribute"; @Table export class VariableStatus extends Model { @@ -14,7 +13,7 @@ export class VariableStatus extends Model { declare customData?: CustomDataType; - @Column(DataType.STRING) + @Column(DataType.STRING(4000)) declare value: string; @Column(DataType.STRING) diff --git a/01_Data/src/layers/sequelize/model/Location/ChargingStation.ts b/01_Data/src/layers/sequelize/model/Location/ChargingStation.ts new file mode 100644 index 000000000..6a4af4fa4 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Location/ChargingStation.ts @@ -0,0 +1,33 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { Namespace } from "@citrineos/base"; +import { Table, Model, PrimaryKey, Column, DataType, BelongsTo, ForeignKey } from "sequelize-typescript"; +import { Location } from "./Location"; + +/** + * Represents a charging station. + * Currently, this data model is internal to CitrineOS. In the future, it will be analogous to an OCPI ChargingStation. + */ +@Table +export class ChargingStation extends Model { + static readonly MODEL_NAME: string = Namespace.ChargingStation; + + @PrimaryKey + @Column(DataType.STRING(36)) + declare id: string; + + @Column + declare isOnline: boolean; + + @ForeignKey(() => Location) + @Column(DataType.INTEGER) + declare locationId?: number; + + /** + * The business Location of the charging station. Optional in case a charging station is not yet in the field, or retired. + */ + @BelongsTo(() => Location) + declare location?: Location; +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/Location/Location.ts b/01_Data/src/layers/sequelize/model/Location/Location.ts new file mode 100644 index 000000000..809a1be23 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Location/Location.ts @@ -0,0 +1,28 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { Namespace } from "@citrineos/base"; +import { Table, Model, DataType, Column, HasMany } from "sequelize-typescript"; +import { ChargingStation } from "./ChargingStation"; + +/** + * Represents a location. + * Currently, this data model is internal to CitrineOS. In the future, it will be analogous to an OCPI Location. + */ +@Table +export class Location extends Model { + static readonly MODEL_NAME: string = Namespace.Location; + + @Column(DataType.STRING) + declare name: string; + + /** + * [longitude, latitude] + */ + @Column(DataType.GEOMETRY('POINT')) + declare coordinates: [number, number]; + + @HasMany(() => ChargingStation) + declare chargingPool: [ChargingStation, ...ChargingStation[]]; +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/Location/index.ts b/01_Data/src/layers/sequelize/model/Location/index.ts new file mode 100644 index 000000000..22d75d385 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Location/index.ts @@ -0,0 +1,6 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +export { Location } from "./Location" +export { ChargingStation } from "./ChargingStation" \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/MessageInfo/MessageInfo.ts b/01_Data/src/layers/sequelize/model/MessageInfo/MessageInfo.ts new file mode 100644 index 000000000..780c21f43 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/MessageInfo/MessageInfo.ts @@ -0,0 +1,90 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { + Namespace, + CustomDataType, + ComponentType, + MessageInfoType, + MessagePriorityEnumType, + MessageStateEnumType, + MessageContentType +} from "@citrineos/base"; +import { Table, Model, AutoIncrement, Column, DataType, PrimaryKey, Index, BelongsTo, ForeignKey } from "sequelize-typescript"; +import { Component } from "../DeviceModel"; + +@Table +export class MessageInfo extends Model implements MessageInfoType { + + static readonly MODEL_NAME: string = Namespace.MessageInfoType; + + declare customData?: CustomDataType; + + /** + * Fields + */ + + @PrimaryKey + @AutoIncrement + @Column(DataType.INTEGER) + declare databaseId: number; + + @Index + @Column({ + unique: 'stationId_id' + }) + declare stationId: string; + + @Column({ + unique: 'stationId_id', + type: DataType.INTEGER + }) + declare id: number; + + @Column(DataType.STRING) + declare priority: MessagePriorityEnumType; + + @Column(DataType.STRING) + declare state?: MessageStateEnumType; + + @Column({ + type: DataType.DATE, + get() { + const startDateTime: Date = this.getDataValue('startDateTime'); + return startDateTime ? startDateTime.toISOString() : null; + } + }) + declare startDateTime?: string; + + @Column({ + type: DataType.DATE, + get() { + const endDateTime: Date = this.getDataValue('endDateTime'); + return endDateTime ? endDateTime.toISOString() : null; + } + }) + declare endDateTime?: string; + + @Column(DataType.STRING) + declare transactionId?: string; + + @Column(DataType.JSON) + declare message: MessageContentType; + + @Column(DataType.BOOLEAN) + declare active: boolean; + + /** + * Relations + */ + + @BelongsTo(() => Component) + declare component: ComponentType; + + @ForeignKey(() => Component) + @Column({ + type: DataType.INTEGER, + }) + declare componentId?: number; +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/MessageInfo/index.ts b/01_Data/src/layers/sequelize/model/MessageInfo/index.ts new file mode 100644 index 000000000..1ef8ce375 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/MessageInfo/index.ts @@ -0,0 +1,4 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 +export { MessageInfo } from "./MessageInfo"; \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/Subscription/Subscription.ts b/01_Data/src/layers/sequelize/model/Subscription/Subscription.ts new file mode 100644 index 000000000..a9e4363f5 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Subscription/Subscription.ts @@ -0,0 +1,41 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { Namespace } from "@citrineos/base"; +import { Column, Index, Model, Table } from "sequelize-typescript"; + +@Table +export class Subscription extends Model { + static readonly MODEL_NAME: string = Namespace.Subscription; + + @Index + @Column + declare stationId: string; + + @Column({ + defaultValue: false + }) + declare onConnect: boolean; + + @Column({ + defaultValue: false + }) + declare onClose: boolean; + + @Column({ + defaultValue: false + }) + declare onMessage: boolean; + + @Column({ + defaultValue: false + }) + declare sentMessage: boolean; + + @Column + declare messageRegexFilter?: string; + + @Column + declare url: string; +} diff --git a/01_Data/src/layers/sequelize/model/Subscription/index.ts b/01_Data/src/layers/sequelize/model/Subscription/index.ts new file mode 100644 index 000000000..b1a189d6a --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Subscription/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +export { Subscription } from "./Subscription"; \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/Tariff/Tariffs.ts b/01_Data/src/layers/sequelize/model/Tariff/Tariffs.ts new file mode 100644 index 000000000..5a7c4ab20 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Tariff/Tariffs.ts @@ -0,0 +1,29 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { + Namespace +} from "@citrineos/base"; +import { Table, Model, Column, DataType, Index } from "sequelize-typescript"; +import { TariffUnitEnumType } from "./index"; + +@Table +export class Tariff extends Model { + + static readonly MODEL_NAME: string = Namespace.Tariff; + + /** + * Fields + */ + + @Index + @Column(DataType.STRING) + declare stationId: string; + + @Column(DataType.STRING) + declare unit: TariffUnitEnumType; + + @Column(DataType.DECIMAL) + declare price: number; +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/Tariff/index.ts b/01_Data/src/layers/sequelize/model/Tariff/index.ts new file mode 100644 index 000000000..6681e02ba --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Tariff/index.ts @@ -0,0 +1,8 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 +export { Tariff } from "./Tariffs"; + +export const enum TariffUnitEnumType { + KWH = "KWH" // Kilowatt-hours (Energy) +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/TransactionEvent/Transaction.ts b/01_Data/src/layers/sequelize/model/TransactionEvent/Transaction.ts index d6fd82315..05f421253 100644 --- a/01_Data/src/layers/sequelize/model/TransactionEvent/Transaction.ts +++ b/01_Data/src/layers/sequelize/model/TransactionEvent/Transaction.ts @@ -5,16 +5,14 @@ import { ChargingStateEnumType, CustomDataType, EVSEType, MeterValueType, Namespace, ReasonEnumType, TransactionEventRequest, TransactionType } from '@citrineos/base'; import { - Table, - Column, - Model, - DataType, - ForeignKey, - HasMany, - BelongsTo, - BelongsToMany, - } from 'sequelize-typescript'; -import { IdToken } from '../Authorization'; + Table, + Column, + Model, + DataType, + ForeignKey, + HasMany, + BelongsTo +} from 'sequelize-typescript'; import { MeterValue } from './MeterValue'; import { TransactionEvent } from './TransactionEvent'; import { Evse } from '../DeviceModel'; @@ -35,10 +33,7 @@ export class Transaction extends Model implements TransactionType { declare evse?: EVSEType; @ForeignKey(() => Evse) - @Column({ - type: DataType.INTEGER, - unique: 'evse_name_instance' - }) + @Column(DataType.INTEGER) declare evseDatabaseId?: number; @Column({ @@ -63,6 +58,9 @@ export class Transaction extends Model implements TransactionType { @Column(DataType.BIGINT) declare timeSpentCharging?: number; + @Column(DataType.DECIMAL) + declare totalKwh?: number; + @Column(DataType.STRING) declare stoppedReason?: ReasonEnumType; diff --git a/01_Data/src/layers/sequelize/model/VariableMonitoring/EventData.ts b/01_Data/src/layers/sequelize/model/VariableMonitoring/EventData.ts new file mode 100644 index 000000000..3fef39dc2 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/VariableMonitoring/EventData.ts @@ -0,0 +1,76 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 +import { + ComponentType, + CustomDataType, + EventDataType, + EventNotificationEnumType, + EventTriggerEnumType, + Namespace, + VariableType +} from "@citrineos/base"; +import {BelongsTo, Column, DataType, ForeignKey, Index, Model, Table} from "sequelize-typescript"; +import {Component, Variable} from "../DeviceModel"; + +@Table +export class EventData extends Model implements EventDataType { + static readonly MODEL_NAME: string = Namespace.EventDataType; + declare customData?: CustomDataType; + + /** + * Fields + */ + @Index + @Column({ + unique: 'stationId' + }) + declare stationId: string; + + @Column(DataType.INTEGER) + declare eventId: number; + declare timestamp: string; + + @Column(DataType.STRING) + declare trigger: EventTriggerEnumType; + + @Column(DataType.INTEGER) + declare cause?: number; + + declare actualValue: string; + + declare techCode?: string; + + declare techInfo?: string; + + @Column(DataType.BOOLEAN) + declare cleared?: boolean; + declare transactionId?: string; + + @Column(DataType.INTEGER) + declare variableMonitoringId?: number; + + @Column(DataType.STRING) + declare eventNotificationType: EventNotificationEnumType; + + /** + * Relations + */ + @BelongsTo(() => Variable) + declare variable: VariableType; + + @ForeignKey(() => Variable) + @Column({ + type: DataType.INTEGER, + }) + declare variableId?: number; + + @BelongsTo(() => Component) + declare component: ComponentType; + + @ForeignKey(() => Component) + @Column({ + type: DataType.INTEGER, + }) + declare componentId?: number; +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/VariableMonitoring/VariableMonitoring.ts b/01_Data/src/layers/sequelize/model/VariableMonitoring/VariableMonitoring.ts new file mode 100644 index 000000000..649452ab5 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/VariableMonitoring/VariableMonitoring.ts @@ -0,0 +1,70 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { Namespace, CustomDataType, VariableMonitoringType, MonitorEnumType, VariableType, ComponentType } from "@citrineos/base"; +import { Table, Model, AutoIncrement, Column, DataType, PrimaryKey, Index, BelongsTo, ForeignKey } from "sequelize-typescript"; +import { Variable, Component } from "../DeviceModel"; + +@Table +export class VariableMonitoring extends Model implements VariableMonitoringType { + + static readonly MODEL_NAME: string = Namespace.VariableMonitoringType; + + declare customData?: CustomDataType; + + /** + * Fields + */ + + @PrimaryKey + @AutoIncrement + @Column(DataType.INTEGER) + declare databaseId: number; + + @Index + @Column({ + unique: 'stationId_Id' + }) + declare stationId: string; + + @Column({ + type: DataType.INTEGER, + unique: 'stationId_Id' + }) + declare id: number; + + @Column(DataType.BOOLEAN) + declare transaction: boolean; + + @Column(DataType.INTEGER) + declare value: number; + + @Column(DataType.STRING) + declare type: MonitorEnumType; + + @Column(DataType.INTEGER) + declare severity: number; + + /** + * Relations + */ + + @BelongsTo(() => Variable) + declare variable: VariableType; + + @ForeignKey(() => Variable) + @Column({ + type: DataType.INTEGER, + }) + declare variableId?: number; + + @BelongsTo(() => Component) + declare component: ComponentType; + + @ForeignKey(() => Component) + @Column({ + type: DataType.INTEGER, + }) + declare componentId?: number; +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/model/VariableMonitoring/VariableMonitoringStatus.ts b/01_Data/src/layers/sequelize/model/VariableMonitoring/VariableMonitoringStatus.ts new file mode 100644 index 000000000..93a6f2273 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/VariableMonitoring/VariableMonitoringStatus.ts @@ -0,0 +1,33 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import {CustomDataType, Namespace} from "@citrineos/base"; +import {StatusInfoType} from "@citrineos/base"; +import {BelongsTo, Column, DataType, ForeignKey, Model, Table} from "sequelize-typescript"; +import {VariableMonitoring} from "./VariableMonitoring"; + +@Table +export class VariableMonitoringStatus extends Model { + + static readonly MODEL_NAME: string = Namespace.VariableMonitoringStatus; + + declare customData?: CustomDataType; + + @Column(DataType.STRING) + declare status: string; + + @Column(DataType.JSON) + declare statusInfo?: StatusInfoType; + + /** + * Relations + */ + + @BelongsTo(() => VariableMonitoring) + declare variable: VariableMonitoring; + + @ForeignKey(() => VariableMonitoring) + @Column(DataType.INTEGER) + declare variableMonitoringId?: number; +} diff --git a/01_Data/src/layers/sequelize/model/VariableMonitoring/index.ts b/01_Data/src/layers/sequelize/model/VariableMonitoring/index.ts new file mode 100644 index 000000000..2a2ba7bb1 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/VariableMonitoring/index.ts @@ -0,0 +1,6 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 +export { EventData } from "./EventData"; +export { VariableMonitoring } from "./VariableMonitoring"; +export { VariableMonitoringStatus } from "./VariableMonitoringStatus"; \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/repository/Authorization.ts b/01_Data/src/layers/sequelize/repository/Authorization.ts index 56b3b43cd..75604ea86 100644 --- a/01_Data/src/layers/sequelize/repository/Authorization.ts +++ b/01_Data/src/layers/sequelize/repository/Authorization.ts @@ -55,15 +55,13 @@ export class AuthorizationRepository extends SequelizeRepository valueIdTokenInfo.groupIdTokenId = savedGroupIdToken.id; } authorizationModel.idTokenInfoId = (await valueIdTokenInfo.save()).id; - await authorizationModel.save(); } } else if (authorizationModel.idTokenInfoId) { // Remove idTokenInfo authorizationModel.idTokenInfoId = undefined; authorizationModel.idTokenInfo = undefined; - await authorizationModel.save(); } - return authorizationModel.reload(); + return authorizationModel.save(); } diff --git a/01_Data/src/layers/sequelize/repository/DeviceModel.ts b/01_Data/src/layers/sequelize/repository/DeviceModel.ts index 91c2391d8..38dc6f745 100644 --- a/01_Data/src/layers/sequelize/repository/DeviceModel.ts +++ b/01_Data/src/layers/sequelize/repository/DeviceModel.ts @@ -3,95 +3,117 @@ // // SPDX-License-Identifier: Apache 2.0 -import { AttributeEnumType, ComponentType, GetVariableResultType, ReportDataType, SetVariableDataType, SetVariableResultType, SetVariableStatusEnumType, StatusInfoType, VariableType } from "@citrineos/base"; -import { VariableAttributeQuerystring } from "../../../interfaces/queries/VariableAttribute"; +import { AttributeEnumType, ComponentType, DataEnumType, GetVariableResultType, MutabilityEnumType, ReportDataType, SetVariableDataType, SetVariableResultType, VariableType } from "@citrineos/base"; +import { VariableAttributeQuerystring } from "../../../interfaces"; import { SequelizeRepository } from "./Base"; import { IDeviceModelRepository } from "../../../interfaces"; import { Op } from "sequelize"; import { VariableAttribute, Component, Evse, Variable, VariableCharacteristics } from "../model/DeviceModel"; -import { VariableStatus } from "../model/DeviceModel/VariableStatus"; +import { VariableStatus } from "../model/DeviceModel"; +import { ComponentVariable } from "../model/DeviceModel/ComponentVariable"; // TODO: Document this export class DeviceModelRepository extends SequelizeRepository implements IDeviceModelRepository { async createOrUpdateDeviceModelByStationId(value: ReportDataType, stationId: string): Promise { - const component: ComponentType = value.component; - const variable: VariableType = value.variable; - let savedComponent = await this.s.models[Component.MODEL_NAME].findOne({ - where: { name: component.name, instance: component.instance ? component.instance : null }, - include: component.evse ? [{ model: Evse, where: { id: component.evse.id, connectorId: component.evse.connectorId ? component.evse.connectorId : null } }] : [Evse] - }); - if (!savedComponent) { - // Create component if not exists - savedComponent = await Component.build({ - ...component - }, { include: [Evse] }).save(); - } - let savedVariable = await this.s.models[Variable.MODEL_NAME].findOne({ - where: { name: variable.name, instance: variable.instance ? variable.instance : null }, - include: [{ model: Component, where: { id: savedComponent.get('id') } }] - }); - if (!savedVariable) { - // Create variable if not exists - savedVariable = await Variable.build({ - componentId: savedComponent.get('id'), - ...variable - }).save(); + // Doing this here so that no records are created if the data is invalid + const variableAttributeTypes = value.variableAttribute.map(attr => attr.type ? attr.type : AttributeEnumType.Actual) + if (variableAttributeTypes.length != (new Set(variableAttributeTypes)).size) { + throw new Error("All variable attributes in ReportData must have different types."); } - let savedVariableCharacteristics: VariableCharacteristics | undefined = await this.s.models[VariableCharacteristics.MODEL_NAME].findOne({ - where: { variableId: savedVariable.get('id') } - }).then(row => (row as VariableCharacteristics)); + + const [component, variable] = await this.findOrCreateEvseAndComponentAndVariable(value.component, value.variable, stationId); + + let dataType: DataEnumType | null = null; if (value.variableCharacteristics) { - const variableCharacteristicsModel = VariableCharacteristics.build({ - variableId: savedVariable.get('id'), - ...value.variableCharacteristics + const [variableCharacteristics, variableCharacteristicsCreated] = await VariableCharacteristics.upsert({ + ...value.variableCharacteristics, + variable: variable, + variableId: variable.id }); - // TODO: Although VariableCharacteristics is optional, VariableCharacteristics.dataType is a vital field for understanding VariableAttribute.value and should be set to some default and incorporated in handling VariableAttribute.value - // Create or update variable characteristics - if (savedVariableCharacteristics) { - for (const k in variableCharacteristicsModel.dataValues) { - savedVariableCharacteristics.setDataValue(k, variableCharacteristicsModel.getDataValue(k)); - } - savedVariableCharacteristics = await savedVariableCharacteristics.save(); - } else { - savedVariableCharacteristics = await variableCharacteristicsModel.save(); - } + dataType = variableCharacteristics.dataType; } - const savedVariableAttributes: VariableAttribute[] = []; - const evseDatabaseId = savedComponent.get('evseDatabaseId'); - for (const variableAttribute of value.variableAttribute) { - const variableAttributeModel = VariableAttribute.build({ + + return await Promise.all(value.variableAttribute.map(async variableAttribute => { + // Even though defaults are set on the VariableAttribute model, those only apply when creating an object + // So we need to set them here to ensure they are set correctly when updating + const [savedVariableAttribute, variableAttributeCreated] = await VariableAttribute.upsert({ stationId: stationId, - variableId: savedVariable.get('id'), - componentId: savedComponent.get('id'), - evseDatabaseId: evseDatabaseId, - dataType: savedVariableCharacteristics ? savedVariableCharacteristics.dataType : undefined, - ...variableAttribute - }, { - include: [{ model: Variable, where: { id: savedVariable.get('id') }, include: [VariableCharacteristics] }, - { model: Component, where: { id: savedComponent.get('id') }, include: evseDatabaseId ? [{ model: Evse, where: { databaseId: evseDatabaseId } }] : [] }] + variableId: variable.id, + componentId: component.id, + evseDatabaseId: component.evseDatabaseId, + type: variableAttribute.type ? variableAttribute.type : AttributeEnumType.Actual, + dataType: dataType, + value: variableAttribute.value, + mutability: variableAttribute.mutability ? variableAttribute.mutability : MutabilityEnumType.ReadWrite, + persistent: variableAttribute.persistent ? variableAttribute.persistent : false, + constant: variableAttribute.constant ? variableAttribute.constant : false }); - let savedVariableAttribute = await super.readByQuery({ - where: { stationId: stationId, type: variableAttribute.type ? variableAttribute.type : AttributeEnumType.Actual }, - include: [{ model: Variable, where: { id: savedVariable.get('id') }, include: [VariableCharacteristics] }, - { model: Component, where: { id: savedComponent.get('id') }, include: evseDatabaseId ? [{ model: Evse, where: { databaseId: evseDatabaseId } }] : [] }] - }, VariableAttribute.MODEL_NAME) - // Create or update variable attribute - if (savedVariableAttribute) { - for (const k in variableAttributeModel.dataValues) { - if (k !== 'id') { // id is not a field that can be updated - const updatedValue = variableAttributeModel.getDataValue(k); - savedVariableAttribute.setDataValue(k, updatedValue); - } - } - savedVariableAttribute = await savedVariableAttribute.save(); - } else { // Reload in order to eager load (otherwise component & variable will be undefined) - savedVariableAttribute = await (await variableAttributeModel.save()).reload(); + return savedVariableAttribute; + })); + } + async findOrCreateEvseAndComponentAndVariable(componentType: ComponentType, variableType: VariableType, stationId: string): Promise<[Component, Variable]> { + const component = await this.findOrCreateEvseAndComponent(componentType, stationId); + + const [variable, variableCreated] = await Variable.findOrCreate({ + where: { name: variableType.name, instance: variableType.instance ? variableType.instance : null }, + defaults: { + ...variableType } - savedVariableAttributes.push(savedVariableAttribute); + }); + + // This can happen asynchronously + ComponentVariable.findOrCreate({ + where: { componentId: component.id, variableId: variable.id } + }) + + return [component, variable] + } + + async findOrCreateEvseAndComponent(componentType: ComponentType, stationId: string): Promise { + const evse = componentType.evse ? (await Evse.findOrCreate({ where: { id: componentType.evse.id, connectorId: componentType.evse.connectorId ? componentType.evse.connectorId : null } }))[0] : undefined; + + const [component, componentCreated] = await Component.findOrCreate({ + where: { name: componentType.name, instance: componentType.instance ? componentType.instance : null }, + defaults: { // Explicit assignment because evse field is a relation and is not able to accept a default value + name: componentType.name, + instance: componentType.instance + } + }); + // Note: this permits changing the evse related to the component + if (component.evseDatabaseId !== evse?.databaseId && evse) { + await component.update({ evseDatabaseId: evse.databaseId }); } - return savedVariableAttributes; + + if (componentCreated) { + // Excerpt from OCPP 2.0.1 Part 1 Architecture & Topology - 4.2 : + // "When a Charging Station does not report: Present, Available and/or Enabled + // the Central System SHALL assume them to be readonly and set to true." + // These default variables and their attributes are created here if the component is new, + // and they will be overwritten if they are included in the update + const defaultComponentVariableNames = ['Present', 'Available', 'Enabled']; + for (const defaultComponentVariableName of defaultComponentVariableNames) { + const [defaultComponentVariable, defaultComponentVariableCreated] = await Variable.findOrCreate({ where: { name: defaultComponentVariableName, instance: null } }); + + // This can happen asynchronously + ComponentVariable.findOrCreate({ + where: { componentId: component.id, variableId: defaultComponentVariable.id } + }) + + await VariableAttribute.create({ + stationId: stationId, + variableId: defaultComponentVariable.id, + componentId: component.id, + evseDatabaseId: evse?.databaseId, + dataType: DataEnumType.boolean, + value: 'true', + mutability: MutabilityEnumType.ReadOnly + }); + } + } + + return component; } async createOrUpdateByGetVariablesResultAndStationId(getVariablesResult: GetVariableResultType[], stationId: string): Promise { @@ -99,18 +121,10 @@ export class DeviceModelRepository extends SequelizeRepository { let savedVariableAttributes: VariableAttribute[] = []; for (const data of setVariablesData) { - savedVariableAttributes = await savedVariableAttributes.concat(await this.createOrUpdateDeviceModelByStationId({ + const savedVariableAttribute = (await this.createOrUpdateDeviceModelByStationId({ component: { - name: data.component.name, - instance: data.component.instance, - ...(data.component.evse ? { - evse: { - id: data.component.evse.id, - connectorId: data.component.evse.connectorId - } - } : {}) + ...data.component }, variable: { - name: data.variable.name, - instance: data.variable.instance + ...data.variable }, variableAttribute: [ { @@ -154,7 +160,8 @@ export class DeviceModelRepository extends SequelizeRepository { const savedVariableAttribute = await super.readByQuery({ where: { stationId: stationId, type: result.attributeType ? result.attributeType : AttributeEnumType.Actual }, - include: [VariableStatus, - { - model: Component, where: { name: result.component.name, instance: result.component.instance ? result.component.instance : null }, - include: result.component.evse ? [{ model: Evse, where: { id: result.component.evse.id, connectorId: result.component.evse.connectorId ? result.component.evse.connectorId : null } }] : [] - }, - { model: Variable, where: { name: result.variable.name, instance: result.variable.instance ? result.variable.instance : null } }] + include: [{ model: Component, where: { name: result.component.name, instance: result.component.instance ? result.component.instance : null } }, + { model: Variable, where: { name: result.variable.name, instance: result.variable.instance ? result.variable.instance : null } }] }, VariableAttribute.MODEL_NAME); if (savedVariableAttribute) { - const savedVariableStatusArray = [await VariableStatus.build({ + await VariableStatus.create({ value: savedVariableAttribute.value, status: result.attributeStatus, statusInfo: result.attributeStatusInfo, variableAttributeId: savedVariableAttribute.get('id') - }, { include: [VariableAttribute] }).save()]; - savedVariableAttribute.statuses = savedVariableAttribute.statuses ? savedVariableAttribute.statuses.concat(savedVariableStatusArray) : savedVariableStatusArray; - return savedVariableAttribute; + }); + // Reload in order to include the statuses + return savedVariableAttribute.reload({ + include: [VariableStatus] + }); } else { - throw new Error("Unable to update variable attribute..."); + throw new Error("Unable to update variable attribute status..."); } } - readAllSetVariableByStationId(stationId: string): Promise { - return super.readAllByQuery({ + async readAllSetVariableByStationId(stationId: string): Promise { + const variableAttributeArray = await super.readAllByQuery({ where: { stationId: stationId, bootConfigSetId: { [Op.ne]: null } }, include: [{ model: Component, include: [Evse] }, Variable] - }, VariableAttribute.MODEL_NAME) - .then(variableAttributeArray => { - const setVariableDataTypeArray: SetVariableDataType[] = []; - for (const variableAttribute of variableAttributeArray) { - setVariableDataTypeArray.push(this.createSetVariableDataType(variableAttribute)); - } - return setVariableDataTypeArray; - }); + }, VariableAttribute.MODEL_NAME); + + return variableAttributeArray.map(variableAttribute => this.createSetVariableDataType(variableAttribute)); } readAllByQuery(query: VariableAttributeQuerystring): Promise { @@ -214,6 +214,23 @@ export class DeviceModelRepository extends SequelizeRepository { + const component = await Component.findOne({ + where: {name: componentType.name, instance: componentType.instance ? componentType.instance : null} + }) + const variable = await Variable.findOne({ + where: {name: variableType.name, instance: variableType.instance ? variableType.instance : null} + }) + if (variable) { + const variableCharacteristic = await VariableCharacteristics.findOne({ + where: {variableId: variable.get('id')} + }) + variable.variableCharacteristics = variableCharacteristic ? variableCharacteristic : undefined; + } + + return [component, variable] + } + /** * Private Methods */ @@ -226,16 +243,10 @@ export class DeviceModelRepository extends SequelizeRepository implements ILocationRepository { + readChargingStationByStationId(stationId: string): Promise { + return ChargingStation.findByPk(stationId); + } +} diff --git a/01_Data/src/layers/sequelize/repository/MessageInfo.ts b/01_Data/src/layers/sequelize/repository/MessageInfo.ts new file mode 100644 index 000000000..e4989ade1 --- /dev/null +++ b/01_Data/src/layers/sequelize/repository/MessageInfo.ts @@ -0,0 +1,41 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import {SequelizeRepository} from "./Base"; +import {MessageInfo} from "../model/MessageInfo"; +import {IMessageInfoRepository} from "../../../interfaces"; +import {ComponentType, MessageInfoType} from "@citrineos/base"; +import {Component} from "../model/DeviceModel"; + +export class MessageInfoRepository extends SequelizeRepository implements IMessageInfoRepository { + async deactivateAllByStationId(stationId: string): Promise { + await MessageInfo.update({ + active: false + }, + { + where: { + stationId: stationId, + active: true + }, + returning: false + } + ); + } + + async createOrUpdateByMessageInfoTypeAndStationId(message: MessageInfoType, stationId: string, componentId?: number): Promise { + const [savedMessageInfo, messageInfoCreated] = await MessageInfo.upsert({ + stationId: stationId, + componentId: componentId, + id: message.id, + priority: message.priority, + state: message.state, + startDateTime: message.startDateTime, + endDateTime: message.endDateTime, + transactionId: message.transactionId, + message: message.message, + active: true + }) + return savedMessageInfo; + } +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/repository/Subscription.ts b/01_Data/src/layers/sequelize/repository/Subscription.ts new file mode 100644 index 000000000..7884a01b8 --- /dev/null +++ b/01_Data/src/layers/sequelize/repository/Subscription.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { SequelizeRepository, Subscription } from ".."; +import { ISubscriptionRepository } from "../../.."; +import { Model } from "sequelize-typescript"; + + +export class SubscriptionRepository extends SequelizeRepository implements ISubscriptionRepository { + + /** + * Creates a new {@link Subscription} in the database. + * Input is assumed to not have an id, and id will be removed if present. + * Object is rebuilt to ensure access to essential {@link Model} function {@link Model.save()} (Model is extended by Subscription). + * + * @param value {@link Subscription} object which may have been deserialized from JSON + * @returns Saved {@link Subscription} if successful, undefined otherwise + */ + create(value: Subscription): Promise { + const { id, ...rawSubscription } = value; + return super.create(Subscription.build({...rawSubscription})); + } + + readAllByStationId(stationId: string): Promise { + return super.readAllByQuery({ where: { stationId: stationId } }, Subscription.MODEL_NAME); + } + + deleteByKey(key: string): Promise { + return super.deleteByKey(key, Subscription.MODEL_NAME); + } +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/repository/Tariff.ts b/01_Data/src/layers/sequelize/repository/Tariff.ts new file mode 100644 index 000000000..233bb6895 --- /dev/null +++ b/01_Data/src/layers/sequelize/repository/Tariff.ts @@ -0,0 +1,49 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import {SequelizeRepository} from "./Base"; +import {ITariffRepository, TariffQueryString} from "../../../interfaces"; +import {Tariff} from "../model/Tariff"; + +export class TariffRepository extends SequelizeRepository implements ITariffRepository { + async findByStationId(stationId: string): Promise { + return Tariff.findOne({ + where: { + stationId: stationId + } + }); + } + + async createOrUpdateTariff(tariff: Tariff): Promise { + const [storedTariff, tariffCreated] = await Tariff.upsert({ + stationId: tariff.stationId, + unit: tariff.unit, + price: tariff.price + }) + return storedTariff; + } + + async readAllByQuery(query: TariffQueryString): Promise { + return super.readAllByQuery({ + where: { + ...(query.stationId ? {stationId: query.stationId} : {}), + ...(query.unit ? {unit: query.unit} : {}), + ...(query.id ? {id: query.id} : {}) + } + }, Tariff.MODEL_NAME); + } + + async deleteAllByQuery(query: TariffQueryString): Promise { + if (!query.id && !query.stationId && !query.unit) { + throw new Error("Must specify at least one query parameter"); + } + return super.deleteAllByQuery({ + where: { + ...(query.stationId ? {stationId: query.stationId} : {}), + ...(query.unit ? {unit: query.unit} : {}), + ...(query.id ? {id: query.id} : {}) + } + }, Tariff.MODEL_NAME); + } +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/repository/TransactionEvent.ts b/01_Data/src/layers/sequelize/repository/TransactionEvent.ts index e3cb98058..b23021f9f 100644 --- a/01_Data/src/layers/sequelize/repository/TransactionEvent.ts +++ b/01_Data/src/layers/sequelize/repository/TransactionEvent.ts @@ -3,7 +3,13 @@ // // SPDX-License-Identifier: Apache 2.0 -import { TransactionEventRequest, ChargingStateEnumType, IdTokenType, TransactionEventEnumType, EVSEType, TransactionEventResponse } from "@citrineos/base"; +import { + TransactionEventRequest, + ChargingStateEnumType, + IdTokenType, + TransactionEventEnumType, + EVSEType +} from "@citrineos/base"; import { ITransactionEventRepository } from "../../../interfaces"; import { MeterValue, Transaction, TransactionEvent } from "../model/TransactionEvent"; import { SequelizeRepository } from "./Base"; @@ -72,7 +78,7 @@ export class TransactionEventRepository extends SequelizeRepository { return super.readAllByQuery({ where: { stationId: stationId }, - include: [{ model: Transaction, where: { transactionId: transactionId }, include: [IdToken] }, MeterValue, Evse, IdToken] + include: [{ model: Transaction, where: { transactionId: transactionId } }, MeterValue, Evse, IdToken] }, TransactionEvent.MODEL_NAME).then(transactionEvents => { transactionEvents?.forEach(transactionEvent => transactionEvent.transaction = undefined); @@ -83,7 +89,7 @@ export class TransactionEventRepository extends SequelizeRepository { return this.s.models[Transaction.MODEL_NAME].findOne({ where: { stationId: stationId, transactionId: transactionId }, - include: [MeterValue, IdToken] + include: [MeterValue] }) .then(row => (row as Transaction)); } @@ -99,23 +105,33 @@ export class TransactionEventRepository extends SequelizeRepository { - const includeObj = evse ? [ { model: Evse, where: { id: evse.id, connectorId: evse.connectorId ? evse.connectorId : null } }, IdToken ] : [IdToken]; + const includeObj = evse ? [{ model: Evse, where: { id: evse.id, connectorId: evse.connectorId ? evse.connectorId : null } }] : []; return this.s.models[Transaction.MODEL_NAME].findAll({ where: { stationId: stationId, ...(chargingStates ? { chargingState: { [Op.in]: chargingStates } } : {}) }, include: includeObj }).then(row => (row as Transaction[])); } - readAllActiveTransactionByIdToken(idToken: IdTokenType): Promise { + readAllActiveTransactionsByIdToken(idToken: IdTokenType): Promise { return this.s.models[Transaction.MODEL_NAME].findAll({ where: { isActive: true }, include: [{ - model: IdToken, where: { - idToken: idToken.idToken, - type: idToken.type - } + model: TransactionEvent, + include: [{ + model: IdToken, where: { + idToken: idToken.idToken, + type: idToken.type + } + }] }] }) .then(row => (row as Transaction[])); } + + readAllMeterValuesByTransactionDataBaseId(transactionDataBaseId: number): Promise { + return this.s.models[MeterValue.MODEL_NAME].findAll({ + where: { transactionDatabaseId: transactionDataBaseId } + }) + .then(row => (row as MeterValue[])); + } } \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/repository/VariableMonitoring.ts b/01_Data/src/layers/sequelize/repository/VariableMonitoring.ts new file mode 100644 index 000000000..eaca28506 --- /dev/null +++ b/01_Data/src/layers/sequelize/repository/VariableMonitoring.ts @@ -0,0 +1,148 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import {SequelizeRepository} from "./Base"; +import {EventData, VariableMonitoring, VariableMonitoringStatus} from "../model/VariableMonitoring"; +import {IVariableMonitoringRepository} from "../../../interfaces"; +import { + CallAction, + EventDataType, + MonitoringDataType, + SetMonitoringDataType, + SetMonitoringResultType, + SetMonitoringStatusEnumType +} from "@citrineos/base"; +import {Component, Variable} from "../model/DeviceModel"; + +export class VariableMonitoringRepository extends SequelizeRepository implements IVariableMonitoringRepository { + + async createOrUpdateByMonitoringDataTypeAndStationId(value: MonitoringDataType, componentId: string, variableId: string, stationId: string): Promise { + return await Promise.all(value.variableMonitoring.map(async variableMonitoring => { + const [savedVariableMonitoring, variableMonitoringCreated] = await VariableMonitoring.upsert({ + stationId: stationId, + variableId: variableId, + componentId: componentId, + id: variableMonitoring.id, + transaction: variableMonitoring.transaction, + value: variableMonitoring.value, + type: variableMonitoring.type, + severity: variableMonitoring.severity + }) + + await this.createVariableMonitoringStatus(SetMonitoringStatusEnumType.Accepted, CallAction.NotifyMonitoringReport, savedVariableMonitoring.get('databaseId') as number); + + return savedVariableMonitoring; + })) + } + + async createVariableMonitoringStatus(status: SetMonitoringStatusEnumType, action: CallAction, variableMonitoringId: number): Promise { + await VariableMonitoringStatus.create({ + status: status, + statusInfo: {reasonCode: action}, + variableMonitoringId: variableMonitoringId + }); + } + + async createOrUpdateBySetMonitoringDataTypeAndStationId(value: SetMonitoringDataType, componentId: string, variableId: string, stationId: string): Promise { + const [savedVariableMonitoring, variableMonitoringCreated] = await VariableMonitoring.upsert({ + stationId: stationId, + variableId: variableId, + componentId: componentId, + id: value.id, + transaction: value.transaction, + value: value.value, + type: value.type, + severity: value.severity + }) + return savedVariableMonitoring; + } + + async rejectAllVariableMonitoringsByStationId(action: CallAction, stationId: string): Promise { + await VariableMonitoring.findAll({ + where: { + stationId: stationId + } + }).then(async variableMonitorings => { + for (const variableMonitoring of variableMonitorings) { + await this.createVariableMonitoringStatus(SetMonitoringStatusEnumType.Rejected, action, variableMonitoring.databaseId); + } + }) + } + + async rejectVariableMonitoringByIdAndStationId(action: CallAction, id: number, stationId: string): Promise { + // use findAll since according to the OCPP 2.0.1 installed VariableMonitors should have unique id’s + // but the id’s of removed Installed monitors should have unique id’s + // but the id’s of removed monitors MAY be reused. + await VariableMonitoring.findAll( + { + where: { + id: id, + stationId: stationId + } + } + ).then(async variableMonitorings => { + for (const variableMonitoring of variableMonitorings) { + await this.createVariableMonitoringStatus(SetMonitoringStatusEnumType.Rejected, action, variableMonitoring.databaseId); + } + }) + } + + async updateResultByStationId(result: SetMonitoringResultType, stationId: string): Promise { + const savedVariableMonitoring = await super.readByQuery({ + where: {stationId: stationId, type: result.type, severity: result.severity}, + include: [{model: Component, + where: { + name: result.component.name, + instance: result.component.instance ? result.component.instance : null + } + }, + {model: Variable, + where: { + name: result.variable.name, + instance: result.variable.instance ? result.variable.instance : null + } + }] + }, VariableMonitoring.MODEL_NAME); + + if (savedVariableMonitoring) { + // The Id is only returned from Charging Station when status is accepted. + if (result.status === SetMonitoringStatusEnumType.Accepted) { + await savedVariableMonitoring.update({ + id: result.id, + }) + } + + await VariableMonitoringStatus.create({ + status: result.status, + statusInfo: result.statusInfo, + variableMonitoringId: savedVariableMonitoring.get('databaseId') + }); + // Reload in order to include the statuses + return savedVariableMonitoring.reload({ + include: [VariableMonitoringStatus] + }); + } else { + throw new Error(`Unable to update set monitoring result: ${result}`); + } + } + + async createEventDatumByComponentIdAndVariableIdAndStationId(event: EventDataType, componentId: string, variableId: string, stationId: string): Promise { + return await EventData.create({ + stationId: stationId, + variableId: variableId, + componentId: componentId, + eventId: event.eventId, + timestamp: event.timestamp, + trigger: event.trigger, + cause: event.cause, + actualValue: event.actualValue, + techCode: event.techCode, + techInfo: event.techInfo, + cleared: event.cleared, + transactionId: event.transactionId, + variableMonitoringId: event.variableMonitoringId, + eventNotificationType: event.eventNotificationType, + }) + } +} \ No newline at end of file diff --git a/01_Data/src/layers/sequelize/util.ts b/01_Data/src/layers/sequelize/util.ts index 4e6048be6..5c085df55 100644 --- a/01_Data/src/layers/sequelize/util.ts +++ b/01_Data/src/layers/sequelize/util.ts @@ -7,11 +7,11 @@ import { SystemConfig } from "@citrineos/base"; import { Dialect } from "sequelize"; import { Sequelize } from "sequelize-typescript"; import { ILogObj, Logger } from "tslog"; -import { AdditionalInfo, Authorization, IdToken, IdTokenInfo } from "./model/Authorization"; -import { Boot } from "./model/Boot"; -import { Component, Evse, Variable, VariableAttribute, VariableCharacteristics, VariableStatus } from "./model/DeviceModel"; -import { MeterValue, Transaction, TransactionEvent } from "./model/TransactionEvent"; -import { SecurityEvent } from "./model/SecurityEvent"; +import { ComponentVariable } from "./model/DeviceModel/ComponentVariable"; +import { AdditionalInfo, Authorization, Boot, ChargingStation, Component, EventData, Evse, IdToken, IdTokenInfo, Location, MeterValue, SecurityEvent, Subscription, Transaction, TransactionEvent, Variable, VariableAttribute, VariableCharacteristics, VariableMonitoring, VariableMonitoringStatus } from "."; +import { VariableStatus } from "./model/DeviceModel"; +import { MessageInfo } from "./model/MessageInfo"; +import { Tariff } from "./model/Tariff"; export class DefaultSequelizeInstance { @@ -43,10 +43,10 @@ export class DefaultSequelizeInstance { username: config.data.sequelize.username, password: config.data.sequelize.password, storage: config.data.sequelize.storage, - models: [AdditionalInfo, Authorization, Boot, - Component, Evse, IdToken, IdTokenInfo, MeterValue, SecurityEvent, - Transaction, TransactionEvent, VariableAttribute, VariableCharacteristics, - VariableStatus, Variable], + models: [AdditionalInfo, Authorization, Boot, ChargingStation, Component, + ComponentVariable, Evse, EventData, IdToken, IdTokenInfo, Location, MeterValue, MessageInfo, + SecurityEvent, Subscription, Transaction, TransactionEvent, Tariff, VariableAttribute, + VariableCharacteristics, VariableMonitoring, VariableMonitoringStatus, VariableStatus, Variable], logging: (sql: string, timing?: number) => { // TODO: Look into fixing that // sequelizeLogger.debug(timing, sql); diff --git a/01_Data/tsconfig.json b/01_Data/tsconfig.json index db3598d5e..f48be3c5e 100644 --- a/01_Data/tsconfig.json +++ b/01_Data/tsconfig.json @@ -1,20 +1,17 @@ { - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": true, - "outDir": "lib", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, + "extends": "../tsconfig.build.json", "include": [ - "src" + "src/**/*.ts", + "src/**/*.json" ], - "exclude": [ - "node_modules", - "**/__tests__/*" + "compilerOptions": { + "outDir": "./dist/", + "composite": true, + "rootDir": "./src" + }, + "references": [ + { + "path": "../00_Base" + } ] } \ No newline at end of file diff --git a/02_Util/package.json b/02_Util/package.json index eedf7892f..7cb3e02f7 100644 --- a/02_Util/package.json +++ b/02_Util/package.json @@ -2,17 +2,16 @@ "name": "@citrineos/util", "version": "1.0.0", "description": "The OCPP util module which supplies helpful utilities like cache and queue connectors, etc.", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "lib" + "dist" ], "scripts": { "prepublish": "npx eslint ./src", - "prepare": "npm run build", - "build": "tsc", - "install-base": "cd ../00_Base && npm run build && npm pack && cd ../02_Util && npm install ../00_Base/citrineos-base-1.0.0.tgz", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "compile": "npm run clean && tsc -p tsconfig.json", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo" }, "keywords": [ "ocpp", @@ -22,7 +21,9 @@ "license": "Apache-2.0", "devDependencies": { "@types/amqplib": "^0.10.2", + "@types/bcrypt": "^5.0.2", "@types/deasync-promise": "^1.0.0", + "@types/json-schema-faker": "^0.5.4", "@types/uuid": "^9.0.1", "eslint": "^8.48.0", "eslint-config-standard-with-typescript": "^38.0.0", @@ -32,15 +33,22 @@ "typescript": "^5.0.4" }, "dependencies": { - "@citrineos/base": "file:../00_Base/citrineos-base-1.0.0.tgz", + "@citrineos/base": "1.0.0", + "@citrineos/data": "1.0.0", + "@directus/sdk": "^15.0.3", + "@fastify/swagger": "^8.10.1", + "@fastify/swagger-ui": "^1.9.3", "@google-cloud/pubsub": "^3.6.0", "amqplib": "^0.10.3", "class-transformer": "^0.5.1", "deasync-promise": "^1.0.1", + "fastify": "^4.26.2", + "json-schema-faker": "^0.5.6", "kafkajs": "^2.2.4", "mqtt": "^5.1.2", "redis": "^4.6.6", "tslog": "^4.8.2", "uuid": "^9.0.0" - } + }, + "workspace": "../" } diff --git a/02_Util/src/index.ts b/02_Util/src/index.ts index f2367abba..18a6bf5f9 100644 --- a/02_Util/src/index.ts +++ b/02_Util/src/index.ts @@ -6,5 +6,10 @@ export { MemoryCache } from "./cache/memory"; export { RedisCache } from "./cache/redis"; export * from "./queue"; +export * from "./networkconnection"; -export { Timed, Timer, isPromise } from "./util/timer"; \ No newline at end of file +export { Timed, Timer, isPromise } from "./util/timer"; +export { initSwagger } from "./util/swagger"; +export { getSizeOfRequest, getBatches } from "./util/parser"; +export { DirectusUtil } from "./util/directus"; +export { validateLanguageTag } from "./util/validator"; \ No newline at end of file diff --git a/02_Util/src/networkconnection/WebsocketNetworkConnection.ts b/02_Util/src/networkconnection/WebsocketNetworkConnection.ts new file mode 100644 index 000000000..2532fd688 --- /dev/null +++ b/02_Util/src/networkconnection/WebsocketNetworkConnection.ts @@ -0,0 +1,364 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 +/* eslint-disable */ + +import { CacheNamespace, IAuthenticator, ICache, IMessageRouter, SystemConfig, WebsocketServerConfig } from "@citrineos/base"; +import { Duplex } from "stream"; +import * as http from "http"; +import * as https from "https"; +import fs from "fs"; +import { ErrorEvent, MessageEvent, WebSocket, WebSocketServer } from "ws"; +import { Logger, ILogObj } from "tslog"; + +export class WebsocketNetworkConnection { + + protected _cache: ICache; + protected _config: SystemConfig; + protected _logger: Logger; + private _identifierConnections: Map = new Map(); + private _httpServers: (http.Server | https.Server)[]; + private _authenticator: IAuthenticator; + private _router: IMessageRouter; + + constructor( + config: SystemConfig, + cache: ICache, + authenticator: IAuthenticator, + router: IMessageRouter, + logger?: Logger + ) { + this._cache = cache; + this._config = config; + this._logger = logger ? logger.getSubLogger({ name: this.constructor.name }) : new Logger({ name: this.constructor.name }); + this._authenticator = authenticator; + router.networkHook = this.sendMessage.bind(this); + this._router = router; + + this._httpServers = []; + this._config.util.networkConnection.websocketServers.forEach(websocketServerConfig => { + let _httpServer; + switch (websocketServerConfig.securityProfile) { + case 3: // mTLS + _httpServer = https.createServer({ + key: fs.readFileSync(websocketServerConfig.tlsKeysFilepath as string), + cert: fs.readFileSync(websocketServerConfig.tlsCertificateChainFilepath as string), + ca: fs.readFileSync(websocketServerConfig.mtlsCertificateAuthorityRootsFilepath as string), + requestCert: true, + rejectUnauthorized: true + }, this._onHttpRequest.bind(this)); + break; + case 2: // TLS + _httpServer = https.createServer({ + key: fs.readFileSync(websocketServerConfig.tlsKeysFilepath as string), + cert: fs.readFileSync(websocketServerConfig.tlsCertificateChainFilepath as string) + }, this._onHttpRequest.bind(this)); + break; + case 1: + case 0: + default: // No TLS + _httpServer = http.createServer(this._onHttpRequest.bind(this)); + break; + } + + // TODO: stop using handleProtocols and switch to shouldHandle or verifyClient; see https://github.com/websockets/ws/issues/1552 + let _socketServer = new WebSocketServer({ + noServer: true, + handleProtocols: (protocols, req) => this._handleProtocols(protocols, req, websocketServerConfig.protocol), + clientTracking: false + }); + + _socketServer.on('connection', (ws: WebSocket, req: http.IncomingMessage) => this._onConnection(ws, websocketServerConfig.pingInterval, req)); + _socketServer.on('error', (wss: WebSocketServer, error: Error) => this._onError(wss, error)); + _socketServer.on('close', (wss: WebSocketServer) => this._onClose(wss)); + + _httpServer.on('upgrade', (request, socket, head) => + this._upgradeRequest(request, socket, head, _socketServer, websocketServerConfig)); + _httpServer.on('error', (error) => _socketServer.emit('error', error)); + // socketServer.close() will not do anything; use httpServer.close() + _httpServer.on('close', () => _socketServer.emit('close')); + const protocol = websocketServerConfig.securityProfile > 1 ? 'wss' : 'ws'; + _httpServer.listen(websocketServerConfig.port, websocketServerConfig.host, () => { + this._logger.info(`WebsocketServer running on ${protocol}://${websocketServerConfig.host}:${websocketServerConfig.port}/`) + }); + this._httpServers.push(_httpServer); + }); + } + + /** + * Send a message to the charging station specified by the identifier. + * + * @param {string} identifier - The identifier of the client. + * @param {string} message - The message to send. + * @return {boolean} True if the method sends the message successfully, false otherwise. + */ + sendMessage(identifier: string, message: string): Promise { + return new Promise((resolve, reject) => { + this._cache.get(identifier, CacheNamespace.Connections).then(clientConnection => { + if (clientConnection) { + const websocketConnection = this._identifierConnections.get(identifier); + if (websocketConnection && websocketConnection.readyState === WebSocket.OPEN) { + websocketConnection.send(message, (error) => { + if (error) { + this._logger.error("On message send error", error); + reject(error); // Reject the promise with the error + } else { + resolve(true); // Resolve the promise with true indicating success + } + }); + } else { + const errorMsg = "Websocket connection is not ready - " + identifier; + this._logger.fatal(errorMsg); + websocketConnection?.close(1011, errorMsg); + reject(new Error(errorMsg)); // Reject with a new error + } + } else { + const errorMsg = "Cannot identify client connection for " + identifier; + // This can happen when a charging station disconnects in the moment a message is trying to send. + // Retry logic on the message sender might not suffice as charging station might connect to different instance. + this._logger.error(errorMsg); + this._identifierConnections.get(identifier)?.close(1011, "Failed to get connection information for " + identifier); + reject(new Error(errorMsg)); // Reject with a new error + } + }).catch(reject); // In case `_cache.get` fails + }); + } + + shutdown(): void { + this._httpServers.forEach(server => server.close()); + this._router.shutdown(); + } + + private _onHttpRequest(req: http.IncomingMessage, res: http.ServerResponse) { + if (req.method === "GET" && req.url == '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'healthy' })); + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: `Route ${req.method}:${req.url} not found`, error: "Not Found", statusCode: 404 })); + } + } + + /** + * Method to validate websocket upgrade requests and pass them to the socket server. + * + * @param {IncomingMessage} req - The request object. + * @param {Duplex} socket - Websocket duplex stream. + * @param {Buffer} head - Websocket buffer. + * @param {WebSocketServer} wss - Websocket server. + * @param {WebsocketServerConfig} websocketServerConfig - Configuration of the websocket connection. + */ + private async _upgradeRequest(req: http.IncomingMessage, socket: Duplex, head: Buffer, wss: WebSocketServer, websocketServerConfig: WebsocketServerConfig) { + // Failed mTLS and TLS requests are rejected by the server before getting this far + this._logger.debug("On upgrade request", req.method, req.url, req.headers); + + const identifier = this._getClientIdFromUrl(req.url as string); + if (3 > websocketServerConfig.securityProfile && websocketServerConfig.securityProfile > 0) { + // Validate username/password from authorization header + // - The Authorization header is formatted as follows: + // AUTHORIZATION: Basic :)> + const authHeader = req.headers.authorization; + const [username, password] = Buffer.from(authHeader?.split(' ')[1] || '', 'base64').toString().split(':'); + if (username && password) { + if (!(await this._authenticator.authenticate(websocketServerConfig.allowUnknownChargingStations, identifier, username, password))) { + this._logger.warn("Unauthorized", identifier); + this._rejectUpgradeUnauthorized(socket); + return; + } + } else { + this._logger.warn("Auth header missing or incorrectly formatted: ", JSON.stringify(authHeader)); + this._rejectUpgradeUnauthorized(socket); + return; + } + } + + // Register client + const registered = await this._cache.set(identifier, websocketServerConfig.id, CacheNamespace.Connections); + if (!registered) { + this._logger.fatal("Failed to register websocket client", identifier); + return false; + } else { + this._logger.debug("Successfully registered websocket client", identifier); + } + + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req); + }); + } + + /** + * Utility function to reject websocket upgrade requests with 401 status code. + * @param socket - Websocket duplex stream. + */ + private _rejectUpgradeUnauthorized(socket: Duplex) { + socket.write('HTTP/1.1 401 Unauthorized\r\n'); + socket.write('WWW-Authenticate: Basic realm="Access to the WebSocket", charset="UTF-8"\r\n'); + socket.write('\r\n'); + socket.end(); + socket.destroy(); + } + + /** + * Internal method to handle new client connection and ensures supported protocols are used. + * + * @param {Set} protocols - The set of protocols to handle. + * @param {IncomingMessage} req - The request object. + * @param {string} wsServerProtocol - The websocket server protocol. + * @return {boolean|string} - Returns the protocol version if successful, otherwise false. + */ + private _handleProtocols(protocols: Set, req: http.IncomingMessage, wsServerProtocol: string) { + // Only supports configured protocol version + if (protocols.has(wsServerProtocol)) { + return wsServerProtocol; + } + this._logger.error(`Protocol mismatch. Supported protocols: [${[...protocols].join(', ')}], but requested protocol: '${wsServerProtocol}' not supported.`); + // Reject the client trying to connect + return false; + } + + /** + * Internal method to handle the connection event when a WebSocket connection is established. + * This happens after successful protocol exchange with client. + * + * @param {WebSocket} ws - The WebSocket object representing the connection. + * @param {number} pingInterval - The ping interval in seconds. + * @param {IncomingMessage} req - The request object associated with the connection. + * @return {void} + */ + private async _onConnection(ws: WebSocket, pingInterval: number, req: http.IncomingMessage): Promise { + // Pause the WebSocket event emitter until broker is established + ws.pause(); + + const identifier = this._getClientIdFromUrl(req.url as string); + this._identifierConnections.set(identifier, ws); + + try { + // Get IP address of client + const ip = req.headers["x-forwarded-for"]?.toString().split(",")[0].trim() || req.socket.remoteAddress || "N/A"; + const port = req.socket.remotePort as number; + this._logger.info("Client websocket connected", identifier, ip, port); + + this._router.registerConnection(identifier); + + this._logger.info("Successfully connected new charging station.", identifier); + + // Register all websocket events + this._registerWebsocketEvents(identifier, ws, pingInterval); + + // Resume the WebSocket event emitter after events have been subscribed to + ws.resume(); + } catch (error) { + this._logger.fatal("Failed to subscribe to message broker for ", identifier); + ws.close(1011, "Failed to subscribe to message broker for " + identifier); + } + } + + /** + * Internal method to register event listeners for the WebSocket connection. + * + * @param {string} identifier - The unique identifier for the connection. + * @param {WebSocket} ws - The WebSocket object representing the connection. + * @param {number} pingInterval - The ping interval in seconds. + * @return {void} This function does not return anything. + */ + private _registerWebsocketEvents(identifier: string, ws: WebSocket, pingInterval: number): void { + + ws.onerror = (event: ErrorEvent) => { + this._logger.error("Connection error encountered for", identifier, event.error, event.message, event.type); + ws.close(1011, event.message); + }; + + ws.onmessage = (event: MessageEvent) => { + this._onMessage(identifier, event.data.toString()); + }; + + ws.once("close", () => { + // Unregister client + this._logger.info("Connection closed for", identifier); + this._cache.remove(identifier, CacheNamespace.Connections); + this._identifierConnections.delete(identifier); + this._router.deregisterConnection(identifier); + }); + + ws.on("pong", async () => { + this._logger.debug("Pong received for", identifier); + const clientConnection: string | null = await this._cache.get(identifier, CacheNamespace.Connections); + + if (clientConnection) { + // Remove expiration for connection and send ping to client in pingInterval seconds. + await this._cache.set(identifier, clientConnection, CacheNamespace.Connections); + this._ping(identifier, ws, pingInterval); + } else { + this._logger.debug("Pong received for", identifier, "but client is not alive"); + ws.close(1011, "Client is not alive"); + } + }); + + this._ping(identifier, ws, pingInterval); + } + + /** + * Internal method to handle the incoming message from the websocket client. + * + * @param {string} identifier - The client identifier. + * @param {string} message - The incoming message from the client. + * @return {void} This function does not return anything. + */ + private _onMessage(identifier: string, message: string): void { + this._router.onMessage(identifier, message); + } + + /** + * Internal method to handle the error event for the WebSocket server. + * + * @param {WebSocketServer} wss - The WebSocket server instance. + * @param {Error} error - The error object. + * @return {void} This function does not return anything. + */ + private _onError(wss: WebSocketServer, error: Error): void { + this._logger.error(error); + // TODO: Try to recover the Websocket server + } + + /** + * Internal method to handle the event when the WebSocketServer is closed. + * + * @param {WebSocketServer} wss - The WebSocketServer instance. + * @return {void} This function does not return anything. + */ + private _onClose(wss: WebSocketServer): void { + this._logger.debug("Websocket Server closed"); + // TODO: Try to recover the Websocket server + } + + /** + * Internal method to execute a ping operation on a WebSocket connection after a delay of 60 seconds. + * + * @param {string} identifier - The identifier of the client connection. + * @param {WebSocket} ws - The WebSocket connection to ping. + * @param {number} pingInterval - The ping interval in milliseconds. + * @return {void} This function does not return anything. + */ + private async _ping(identifier: string, ws: WebSocket, pingInterval: number): Promise { + setTimeout(async () => { + const clientConnection: string | null = await this._cache.get(identifier, CacheNamespace.Connections); + if (clientConnection) { + this._logger.debug("Pinging client", identifier); + // Set connection expiration and send ping to client + await this._cache.set(identifier, clientConnection, CacheNamespace.Connections, pingInterval * 2); + ws.ping(); + } else { + ws.close(1011, "Client is not alive"); + } + }, pingInterval * 1000); + } + + /** + * + * @param url Http upgrade request url used by charger + * @returns Charger identifier + */ + private _getClientIdFromUrl(url: string): string { + return url.split("/").pop() as string; + } +} \ No newline at end of file diff --git a/02_Util/src/networkconnection/authenticator/Authenticator.ts b/02_Util/src/networkconnection/authenticator/Authenticator.ts new file mode 100644 index 000000000..0a5993cca --- /dev/null +++ b/02_Util/src/networkconnection/authenticator/Authenticator.ts @@ -0,0 +1,64 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { ICache, AttributeEnumType, SetVariableStatusEnumType, IAuthenticator, CacheNamespace } from "@citrineos/base"; +import { Logger, ILogObj } from "tslog"; +import * as bcrypt from "bcrypt"; +import { IDeviceModelRepository, ILocationRepository } from "@citrineos/data"; + +export class Authenticator implements IAuthenticator { + + protected _cache: ICache; + private _locationRepository: ILocationRepository; + private _deviceModelRepository: IDeviceModelRepository; + protected _logger: Logger; + + constructor( + cache: ICache, + locationRepository: ILocationRepository, + deviceModelRepository: IDeviceModelRepository, + logger?: Logger) { + this._cache = cache; + this._locationRepository = locationRepository; + this._deviceModelRepository = deviceModelRepository; + this._logger = logger ? logger.getSubLogger({ name: this.constructor.name }) : new Logger({ name: this.constructor.name }); + } + + async authenticate(allowUnknownChargingStations: boolean, identifier: string, username?: string, password?: string): Promise { + if (!allowUnknownChargingStations && (await this._locationRepository.readChargingStationByStationId(identifier)) == null) { + this._logger.warn("Unknown identifier", identifier); + return false; + + } else if (await this._cache.get(identifier, CacheNamespace.Connections)) { + this._logger.warn("New connection attempted for already connected identifier", identifier); + return false; + } else if (username && password) { + if (username != identifier || await this._checkPassword(username, password) === false) { + this._logger.warn("Unauthorized", identifier); + return false; + } + } + this._logger.debug("Successfully got past the authentication step for identifier", identifier); + return true; + } + + private async _checkPassword(username: string, password: string) { + return (await this._deviceModelRepository.readAllByQuery({ + stationId: username, + component_name: 'SecurityCtrlr', + variable_name: 'BasicAuthPassword', + type: AttributeEnumType.Actual + }).then(r => { + if (r && r[0]) { + // Grabbing value most recently *successfully* set on charger + const hashedPassword = r[0].statuses?.filter(status => status.status !== SetVariableStatusEnumType.Rejected).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).shift(); + if (hashedPassword?.value) { + return bcrypt.compare(password, hashedPassword.value); + } + } + this._logger.warn("Has no password", username); + return false; + })); + } +} \ No newline at end of file diff --git a/02_Util/src/networkconnection/index.ts b/02_Util/src/networkconnection/index.ts new file mode 100644 index 000000000..4b53163f0 --- /dev/null +++ b/02_Util/src/networkconnection/index.ts @@ -0,0 +1,6 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +export { Authenticator } from "./authenticator/Authenticator" +export { WebsocketNetworkConnection } from "./WebsocketNetworkConnection" diff --git a/02_Util/src/queue/google-pubsub/receiver.ts b/02_Util/src/queue/google-pubsub/receiver.ts index e831ba98d..9458752eb 100644 --- a/02_Util/src/queue/google-pubsub/receiver.ts +++ b/02_Util/src/queue/google-pubsub/receiver.ts @@ -11,7 +11,7 @@ import { } from "@google-cloud/pubsub"; import { ILogObj, Logger } from "tslog"; import { MemoryCache } from "../../cache/memory"; -import { AbstractMessageHandler, ICache, IModule, SystemConfig, CallAction, CacheNamespace, IMessage, OcppRequest, OcppResponse, HandlerProperties, Message, OcppError, RetryMessageError } from "@citrineos/base"; +import { AbstractMessageHandler, ICache, IModule, SystemConfig, CallAction, CacheNamespace, OcppRequest, OcppResponse, Message, OcppError, RetryMessageError } from "@citrineos/base"; import { plainToInstance } from "class-transformer"; /** @@ -28,7 +28,6 @@ export class PubSubReceiver extends AbstractMessageHandler { */ private _cache: ICache; private _client: PubSub; - private _module?: IModule; private _subscriptions: Subscription[] = []; /** @@ -36,11 +35,10 @@ export class PubSubReceiver extends AbstractMessageHandler { * * @param topicPrefix Custom topic prefix, defaults to "ocpp" */ - constructor(config: SystemConfig, logger?: Logger, cache?: ICache, module?: IModule) { - super(config, logger); + constructor(config: SystemConfig, logger?: Logger, module?: IModule, cache?: ICache) { + super(config, logger, module); this._cache = cache || new MemoryCache(); this._client = new PubSub({ servicePath: this._config.util.messageBroker.pubsub?.servicePath }); - this._module = module; } /** @@ -90,16 +88,6 @@ export class PubSubReceiver extends AbstractMessageHandler { }); } - /** - * Method to handle incoming messages. Forwarding to OCPP module. - * - * @param message The incoming {@link IMessage} - * @param context The context of the incoming message, in this implementation it's the PubSub message id - */ - async handle(message: IMessage, props?: HandlerProperties): Promise { - await this._module?.handle(message, props); - } - /** * Shutdown the receiver by closing all subscriptions and deleting them. */ @@ -115,17 +103,6 @@ export class PubSubReceiver extends AbstractMessageHandler { }); } - /** - * Getter & Setter - */ - - get module(): IModule | undefined { - return this._module; - } - set module(value: IModule | undefined) { - this._module = value; - } - /** * Private Methods */ diff --git a/02_Util/src/queue/kafka/receiver.ts b/02_Util/src/queue/kafka/receiver.ts index 050231494..a359f99d2 100644 --- a/02_Util/src/queue/kafka/receiver.ts +++ b/02_Util/src/queue/kafka/receiver.ts @@ -3,7 +3,7 @@ // // SPDX-License-Identifier: Apache 2.0 -import { AbstractMessageHandler, IMessageHandler, IModule, SystemConfig, CallAction, IMessage, OcppRequest, OcppResponse, HandlerProperties, Message, OcppError, RetryMessageError } from "@citrineos/base"; +import { AbstractMessageHandler, IMessageHandler, IModule, SystemConfig, CallAction, OcppRequest, OcppResponse, Message, OcppError, RetryMessageError } from "@citrineos/base"; import { plainToInstance } from "class-transformer"; import { Admin, Consumer, EachMessagePayload, Kafka } from "kafkajs"; import { ILogObj, Logger } from "tslog"; @@ -17,14 +17,12 @@ export class KafkaReceiver extends AbstractMessageHandler implements IMessageHan * Fields */ private _client: Kafka; - private _module?: IModule; private _topicName: string; private _consumerMap: Map; constructor(config: SystemConfig, logger?: Logger, module?: IModule) { - super(config, logger); - - this._module = module; + super(config, logger, module); + this._consumerMap = new Map(); this._client = new Kafka({ brokers: this._config.util.messageBroker.kafka?.brokers || [], @@ -86,27 +84,12 @@ export class KafkaReceiver extends AbstractMessageHandler implements IMessageHan }); } - async handle(message: IMessage, props?: HandlerProperties): Promise { - await this._module?.handle(message, props); - } - shutdown(): void { this._consumerMap.forEach((value) => { value.disconnect(); }); } - /** - * Getter & Setter - */ - - get module(): IModule | undefined { - return this._module; - } - set module(value: IModule | undefined) { - this._module = value; - } - /** * Private Methods */ diff --git a/02_Util/src/queue/rabbit-mq/receiver.ts b/02_Util/src/queue/rabbit-mq/receiver.ts index 7e56fa2c8..c5ef16466 100644 --- a/02_Util/src/queue/rabbit-mq/receiver.ts +++ b/02_Util/src/queue/rabbit-mq/receiver.ts @@ -6,7 +6,7 @@ import * as amqplib from "amqplib"; import { ILogObj, Logger } from "tslog"; import { MemoryCache } from "../.."; -import { AbstractMessageHandler, ICache, IModule, SystemConfig, CallAction, CacheNamespace, IMessage, OcppError, OcppRequest, OcppResponse, HandlerProperties, Message, RetryMessageError } from "@citrineos/base"; +import { AbstractMessageHandler, ICache, IModule, SystemConfig, CallAction, CacheNamespace, OcppError, OcppRequest, OcppResponse, Message, RetryMessageError } from "@citrineos/base"; import { plainToInstance } from "class-transformer"; /** @@ -26,29 +26,16 @@ export class RabbitMqReceiver extends AbstractMessageHandler { protected _cache: ICache; protected _connection?: amqplib.Connection; protected _channel?: amqplib.Channel; - protected _module?: IModule; - constructor(config: SystemConfig, logger?: Logger, cache?: ICache, module?: IModule) { - super(config, logger); + constructor(config: SystemConfig, logger?: Logger, module?: IModule, cache?: ICache) { + super(config, logger, module); this._cache = cache || new MemoryCache(); - this._module = module; this._connect().then(channel => { this._channel = channel; }); } - /** - * Getter & Setter - */ - - get module(): IModule | undefined { - return this._module; - } - set module(value: IModule | undefined) { - this._module = value; - } - /** * Methods */ @@ -132,11 +119,7 @@ export class RabbitMqReceiver extends AbstractMessageHandler { } }); } - - async handle(message: IMessage, props?: HandlerProperties): Promise { - await this._module?.handle(message, props); - } - + shutdown(): Promise { return Promise.resolve(); } diff --git a/02_Util/src/util/directus.ts b/02_Util/src/util/directus.ts new file mode 100644 index 000000000..f431bdba8 --- /dev/null +++ b/02_Util/src/util/directus.ts @@ -0,0 +1,246 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { SystemConfig } from "@citrineos/base"; +import { sequelize } from "@citrineos/data"; +import { authentication, DirectusFlow, DirectusOperation, RestClient, createDirectus, createFlow, createOperation, readFlows, rest, staticToken, updateFlow, updateOperation } from "@directus/sdk"; +import { RouteOptions } from "fastify"; +import { JSONSchemaFaker } from "json-schema-faker"; +import { Logger, ILogObj } from "tslog"; + +interface Schema { + // No custom collections needed +} + +export class DirectusUtil { + + protected readonly _config: SystemConfig; + protected readonly _logger: Logger; + private readonly _client: RestClient; + + constructor(config: SystemConfig, logger?: Logger) { + this._config = config; + this._logger = logger ? logger.getSubLogger({ name: this.constructor.name }) : new Logger({ name: this.constructor.name }); + let client; + if (this._config.util.directus?.token) { // Auth with static token + client = createDirectus(`http://${this._config.util.directus?.host}:${this._config.util.directus?.port}`) + .with(staticToken(this._config.util.directus?.token)).with(rest()); + } else if (this._config.util.directus?.username && this._config.util.directus?.password) { // Auth with username and password + client = createDirectus(`http://${this._config.util.directus?.host}:${this._config.util.directus?.port}`) + .with(authentication()).with(rest()); + this._logger.info(`Logging into Directus as ${this._config.util.directus.username}`); + client.login(this._config.util.directus.username, this._config.util.directus.password); + } else { // No auth + client = createDirectus(`http://${this._config.util.directus?.host}:${this._config.util.directus?.port}`) + .with(rest()); + } + this._client = client; + } + + public addDirectusMessageApiFlowsFastifyRouteHook(routeOptions: RouteOptions) { + const messagePath = routeOptions.url // 'Url' here means the route specified when the endpoint was added to the fastify server, such as '/ocpp/configuration/reset' + if (messagePath.split("/")[1] == "ocpp") { // Message API check: relies on implementation of _toMessagePath in AbstractModuleApi which prefixes url with '/ocpp/' + this._logger.info(`Adding Directus Message API flow for ${messagePath}`); + // Parse action from url: relies on implementation of _toMessagePath in AbstractModuleApi which puts CallAction in final path part + const lowercaseAction: string = messagePath.split("/").pop() as string; + const action = lowercaseAction.charAt(0).toUpperCase() + lowercaseAction.slice(1) + // _addMessageRoute in AbstractModuleApi adds the bodySchema specified in the @MessageEndpoint decorator to the fastify route schema + // These body schemas are the ones generated directly from the specification using the json-schema-processor in 00_Base + const bodySchema = routeOptions.schema!.body as object; + this.addDirectusFlowForAction(action, messagePath, bodySchema); + } + } + + private async addDirectusFlowForAction(action: string, messagePath: string, bodySchema: object) { + JSONSchemaFaker.option({ useExamplesValue: true, useDefaultValue: true, requiredOnly: true, pruneProperties: ["customData"] }); + const bodyData = JSONSchemaFaker.generate(bodySchema); + const flowOptions = { + collections: [ + sequelize.ChargingStation.getTableName() + ], + async: true, + location: "item", + requireConfirmation: true, + confirmationDescription: "Are you sure you want to execute this flow?", + fields: [ + { + field: "citrineUrl", + type: "string", + name: "CitrineOS URL", + meta: { + interface: "select-dropdown", + note: "The URL of the CitrineOS server. For example: http://localhost:8080/.", + width: "full", + required: true, + options: { + placeholder: "e.g. http://localhost:8080/", + trim: true, + iconLeft: "web_asset", + choices: [ + { + text: "Localhost (localhost:8080)", + value: "http://localhost:8080" + }, + { + text: "Docker (citrine:8080)", + value: "http://citrine:8080" + }, + { + text: "Docker Hybrid (host.docker.internal:8080)", + value: "http://host.docker.internal:8080" + } + ], + allowOther: true + } + } + }, + { + field: "tenantId", + type: "string", + name: "Tenant ID", + meta: { + interface: "select-dropdown", + note: "The tenant identifier of the charging station. To be removed in future releases.", + width: "full", + required: true, + options: { + placeholder: "e.g. T01", + trim: true, + choices: [ + { + text: "Default Tenant (T01)", + value: "T01" + } + ], + allowOther: true + } + } + }, + { + field: "payload", + type: "json", + name: "Payload", + meta: { + interface: "input-code", + note: "The payload to be sent in the call to CitrineOS.", + width: "full", + required: true, + options: { + lineWrapping: true, + language: "JSON", + template: JSON.stringify(bodyData, null, 2) + } + } + } + ] + }; + + const flow: Partial> = { + name: action, + color: "#2ECDA7", + description: action, + status: "active", + trigger: "manual", + accountability: "all", + options: flowOptions + }; + + const notificationOperation: Partial> = { + name: "Send Status Notification", + key: "send_status_notification", + type: "notification", + position_x: 20, + position_y: 17, + options: { + recipient: "{{$accountability.user}}", + subject: `${action} - Success: {{$last.data.success}}`, + message: "{{$last.data.payload}}" + } + }; + + const webhookOperation: Partial> = { + name: "CitrineOS Webhook", + key: "citrine_webhook", + type: "request", + position_x: 40, + position_y: 1, + options: { + url: `{{$trigger.body.citrineUrl}}${messagePath}?identifier={{$last.id}}&tenantId={{$trigger.body.tenantId}}`, + method: "POST", + body: "{{$trigger.body.payload}}" + } + }; + + const readOperation: Partial> = { + name: "Read Charging Station Data", + key: "charging_station_read", + type: "item-read", + position_x: 20, + position_y: 1, + options: { + collection: sequelize.ChargingStation.getTableName(), + key: "{{$last.body.keys[0]}}" + } + } + + let errorLogVerb = "reading"; + try { + const readFlowsResponse = await this._client.request(readFlows({ filter: { name: { _eq: action } }, fields: ["id", "name"] })); + + if (readFlowsResponse.length > 0) { + errorLogVerb = "updating"; + this._logger.info("Flow already exists in Directus for ", action, ". Updating Flow."); + + const existingFlow = readFlowsResponse[0]; + this.updateMessageApiFlow(existingFlow.id, flow, notificationOperation, webhookOperation, readOperation); + this._logger.info(`Successfully updated Directus Flow for ${action}`); + } else { + errorLogVerb = "creating"; + + this.createMessageApiFlow(flow, notificationOperation, webhookOperation, readOperation); + this._logger.info(`Successfully created Directus Flow for ${action}`); + } + } catch (error) { + this._logger.error(`Error ${errorLogVerb} Directus Flow: ${JSON.stringify(error)}`); + } + } + + private async createMessageApiFlow(flow: Partial>, notificationOperation: Partial>, webhookOperation: Partial>, readOperation: Partial>): Promise { + // Create flow + const flowCreationResponse = await this._client.request(createFlow(flow)); + + // Create notification operation + notificationOperation.flow = flowCreationResponse.id; + const notificationOperationCreationResponse = await this._client.request(createOperation(notificationOperation)); + + // Create webhook operation + webhookOperation.flow = flowCreationResponse.id; + webhookOperation.resolve = notificationOperationCreationResponse.id + const webhookOperationCreationResponse = await this._client.request(createOperation(webhookOperation)); + + // Create read operation + readOperation.flow = flowCreationResponse.id; + readOperation.resolve = webhookOperationCreationResponse.id; + const readOperationCreationResponse = await this._client.request(createOperation(readOperation)); + + // Update flow with operations + this._client.request(updateFlow(flowCreationResponse.id, { + operation: readOperationCreationResponse.id + })); + } + + private async updateMessageApiFlow(flowId: string, updatedFlow: Partial>, notificationOperation: Partial>, webhookOperation: Partial>, readOperation: Partial>): Promise { + // Update flow + const flowUpdateResponse = await this._client.request(updateFlow(flowId, updatedFlow)); + + // Update read operation + const readOperationUpdateResponse = await this._client.request(updateOperation(flowUpdateResponse.operation as string, readOperation)); + + // Update webhook operation + const webhookOperationUpdateResponse = await this._client.request(updateOperation(readOperationUpdateResponse.resolve, webhookOperation)); + + // Update notification operation + this._client.request(updateOperation(webhookOperationUpdateResponse.resolve, notificationOperation)); + } +} \ No newline at end of file diff --git a/02_Util/src/util/parser.ts b/02_Util/src/util/parser.ts new file mode 100644 index 000000000..213bd2e43 --- /dev/null +++ b/02_Util/src/util/parser.ts @@ -0,0 +1,36 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import {OcppRequest} from '@citrineos/base'; + +/** + * Calculate the size of a request. + * + * @param {object} request - The ocpp request. + * @return {number} The size of the request (Bytes). + */ +export function getSizeOfRequest(request: OcppRequest) : number { + return new TextEncoder().encode(JSON.stringify(request)).length; +} + +/** + * Slice array into pieces according to the given size. + * + * @param {array} array - An array. + * @param {number} size - The expected size of a batch. + * @return {map} A map with index as key and batch as value. Index is the position of the 1st batch element in the given + * array. Batch is a subarray of the given array. + */ +export function getBatches(array: object[] | string[] | boolean[] | number[], size: number): Map { + const batchMap = new Map(); + let lastIndex = 0; + while (array.length > 0) { + const batch = array.slice(0, size); + batchMap.set(lastIndex, batch); + lastIndex += batch.length; + array = array.slice(size); + } + + return batchMap; +} \ No newline at end of file diff --git a/Swarm/src/util/swagger.ts b/02_Util/src/util/swagger.ts similarity index 93% rename from Swarm/src/util/swagger.ts rename to 02_Util/src/util/swagger.ts index 934d968bb..c0378eb64 100644 --- a/Swarm/src/util/swagger.ts +++ b/02_Util/src/util/swagger.ts @@ -2,6 +2,7 @@ // Copyright Contributors to the CitrineOS Project // // SPDX-License-Identifier: Apache 2.0 +/* eslint-disable */ import fastifySwagger from "@fastify/swagger"; import fastifySwaggerUi from "@fastify/swagger-ui"; @@ -60,7 +61,7 @@ function OcppTransformObject({ swaggerObject, openapiObject }: { } } return openapiObject; -}; +} export function initSwagger(systemConfig: SystemConfig, server: FastifyInstance) { server.register(fastifySwagger, { @@ -74,13 +75,9 @@ export function initSwagger(systemConfig: SystemConfig, server: FastifyInstance) transformObject: OcppTransformObject }); - const swaggerUiOptions = { - routePrefix: systemConfig.server.swagger?.path, + const swaggerUiOptions: any = { + routePrefix: systemConfig.util.swagger?.path, exposeRoute: true, - logo: { - type: 'image/png', - content: fs.readFileSync(__dirname + '/../assets/logo.png') - }, uiConfig: { filter: true }, @@ -93,5 +90,12 @@ export function initSwagger(systemConfig: SystemConfig, server: FastifyInstance) } }; + if (systemConfig.util.swagger?.logoPath) { + swaggerUiOptions['logo'] = { + type: 'image/png', + content: fs.readFileSync(systemConfig.util.swagger?.logoPath) + }; + } + server.register(fastifySwaggerUi, swaggerUiOptions); } \ No newline at end of file diff --git a/02_Util/src/util/validator.ts b/02_Util/src/util/validator.ts new file mode 100644 index 000000000..c7dd96e83 --- /dev/null +++ b/02_Util/src/util/validator.ts @@ -0,0 +1,14 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +/** + * Validate a language tag is an RFC-5646 tag, see: {@link https://tools.ietf.org/html/rfc5646}, + * example: US English is: "en-US" + * + * @param languageTag + * @returns {boolean} true if the languageTag is an RFC-5646 tag + */ +export function validateLanguageTag(languageTag: string): boolean { + return /^((?:(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang))|((?:([A-Za-z]{2,3}(-(?:[A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-(?:[A-Za-z]{4}))?(-(?:[A-Za-z]{2}|[0-9]{3}))?(-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-(?:[0-9A-WY-Za-wy-z](-[A-Za-z0-9]{2,8})+))*(-(?:x(-[A-Za-z0-9]{1,8})+))?)|(?:x(-[A-Za-z0-9]{1,8})+))$/.test(languageTag); +} \ No newline at end of file diff --git a/02_Util/tsconfig.json b/02_Util/tsconfig.json index 5cebe347b..fad4e4aaf 100644 --- a/02_Util/tsconfig.json +++ b/02_Util/tsconfig.json @@ -1,21 +1,20 @@ { - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": true, - "outDir": "lib", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, + "extends": "../tsconfig.build.json", "include": [ - "src" + "src/**/*.ts", + "src/**/*.json" ], - "exclude": [ - "node_modules", - "**/__tests__/*" + "compilerOptions": { + "outDir": "./dist/", + "composite": true, + "rootDir": "./src" + }, + "references": [ + { + "path": "../00_Base" + }, + { + "path": "../01_Data" + } ] } \ No newline at end of file diff --git a/03_Modules/Certificates/package.json b/03_Modules/Certificates/package.json index d2e94aa0b..10a332cc4 100644 --- a/03_Modules/Certificates/package.json +++ b/03_Modules/Certificates/package.json @@ -2,20 +2,16 @@ "name": "@citrineos/certificates", "version": "1.0.0", "description": "The certificates module for OCPP v2.0.1. This module is not intended to be used directly, but rather as a dependency for other modules.", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "lib" + "dist" ], "scripts": { "prepublish": "npx eslint", - "prepare": "npm run build", - "build": "tsc", - "refresh-base": "cd ../../00_Base && npm run build && npm pack && cd ../03_Modules/Certificates && npm install ../../00_Base/citrineos-base-1.0.0.tgz", - "refresh-data": "cd ../../01_Data && npm run build && npm pack && cd ../03_Modules/Certificates && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "refresh-util": "cd ../../02_Util && npm run build && npm pack && cd ../03_Modules/Certificates && npm install ../../02_Util/citrineos-util-1.0.0.tgz", - "install-all": "npm install ../../00_Base/citrineos-base-1.0.0.tgz && npm install ../../02_Util/citrineos-util-1.0.0.tgz && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "compile": "npm run clean && tsc -p tsconfig.json", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo" }, "keywords": [ "ocpp", @@ -29,10 +25,11 @@ "typescript": "^5.0.4" }, "dependencies": { - "@citrineos/base": "file:../../00_Base/citrineos-base-1.0.0.tgz", - "@citrineos/data": "file:../../01_Data/citrineos-data-1.0.0.tgz", - "@citrineos/util": "file:../../02_Util/citrineos-util-1.0.0.tgz", + "@citrineos/base": "1.0.0", + "@citrineos/data": "1.0.0", + "@citrineos/util": "1.0.0", "fastify": "^4.22.2", "node-forge": "^1.3.1" - } + }, + "workspace": "../../" } \ No newline at end of file diff --git a/03_Modules/Certificates/src/module/module.ts b/03_Modules/Certificates/src/module/module.ts index 71bed1c64..8b8bc0369 100644 --- a/03_Modules/Certificates/src/module/module.ts +++ b/03_Modules/Certificates/src/module/module.ts @@ -12,10 +12,13 @@ import { CertificateSignedResponse, CertificateSigningUseEnumType, DeleteCertificateResponse, + ErrorCode, EventGroup, GenericStatusEnumType, Get15118EVCertificateRequest, + GetCertificateStatusEnumType, GetCertificateStatusRequest, + GetCertificateStatusResponse, GetInstalledCertificateIdsResponse, HandlerProperties, ICache, @@ -23,6 +26,7 @@ import { IMessageHandler, IMessageSender, InstallCertificateResponse, + OcppError, SignCertificateRequest, SignCertificateResponse, SystemConfig @@ -33,6 +37,7 @@ import deasyncPromise from "deasync-promise"; import * as forge from "node-forge"; import fs from "fs"; import { ILogObj, Logger } from 'tslog'; +import { CacheNamespace } from "@citrineos/base"; /** * Component that handles provisioning related messages. @@ -57,9 +62,9 @@ export class CertificatesModule extends AbstractModule { ]; protected _deviceModelRepository: IDeviceModelRepository; - private _securityCaCert?: forge.pki.Certificate; - private _securityCaPrivateKey?: forge.pki.rsa.PrivateKey; - + private _securityCaCerts: Map = new Map(); + private _securityCaPrivateKeys: Map = new Map(); + /** * Constructor */ @@ -84,12 +89,12 @@ export class CertificatesModule extends AbstractModule { constructor( config: SystemConfig, cache: ICache, - sender?: IMessageSender, - handler?: IMessageHandler, + sender: IMessageSender, + handler: IMessageHandler, logger?: Logger, deviceModelRepository?: IDeviceModelRepository ) { - super(config, cache, handler || new RabbitMqReceiver(config, logger, cache), sender || new RabbitMqSender(config, logger), EventGroup.Certificates, logger); + super(config, cache, handler || new RabbitMqReceiver(config, logger), sender || new RabbitMqSender(config, logger), EventGroup.Certificates, logger); const timer = new Timer(); this._logger.info(`Initializing...`); @@ -100,8 +105,18 @@ export class CertificatesModule extends AbstractModule { this._deviceModelRepository = deviceModelRepository || new sequelize.DeviceModelRepository(config, logger); - this._securityCaCert = this._config.websocketSecurity?.mtlsCertificateAuthorityRootsFilepath ? forge.pki.certificateFromPem(fs.readFileSync(this._config.websocketSecurity.mtlsCertificateAuthorityRootsFilepath as string, 'utf8')) : undefined; - this._securityCaPrivateKey = this._config.websocketSecurity?.mtlsCertificateAuthorityRootsFilepath ? forge.pki.privateKeyFromPem(fs.readFileSync(this._config.websocketSecurity.mtlsCertificateAuthorityKeysFilepath as string, 'utf8')) : undefined; + + this._config.util.networkConnection.websocketServers.forEach(server => { + if (server.securityProfile == 3) { + try { + this._securityCaCerts.set(server.id, forge.pki.certificateFromPem(fs.readFileSync(server.mtlsCertificateAuthorityRootsFilepath as string, 'utf8'))); + this._securityCaPrivateKeys.set(server.id, forge.pki.privateKeyFromPem(fs.readFileSync(server.mtlsCertificateAuthorityKeysFilepath as string, 'utf8'))); + } catch (error) { + this._logger.error("Unable to start Certificates module due to invalid security certificates for {}: {}", server, error); + throw error; + } + } + }); this._logger.info(`Initialized in ${timer.end()}ms...`); } @@ -118,7 +133,8 @@ export class CertificatesModule extends AbstractModule { this._logger.debug("Get15118EVCertificate received:", message, props); - this._logger.error("Get15118EVCertificate not implemented"); + this._logger.error("Get15118EVCertificate not implemented"); + this.sendCallErrorWithMessage(message, new OcppError(message.context.correlationId, ErrorCode.NotImplemented, "Get15118EVCertificate not implemented")); } @AsHandler(CallAction.GetCertificateStatus) @@ -129,7 +145,8 @@ export class CertificatesModule extends AbstractModule { this._logger.debug("GetCertificateStatus received:", message, props); - this._logger.error("GetCertificateStatus not implemented"); + this._logger.error("GetCertificateStatus not implemented"); + this.sendCallResultWithMessage(message, { status: GetCertificateStatusEnumType.Failed, statusInfo: { reasonCode: ErrorCode.NotImplemented } } as GetCertificateStatusResponse); } @@ -157,13 +174,6 @@ export class CertificatesModule extends AbstractModule { const certificateType = message.payload.certificateType; switch (certificateType) { case CertificateSigningUseEnumType.ChargingStationCertificate: - if (!this._securityCaCert || !this._securityCaPrivateKey) { - // this.sendCallResultWithMessage(message, { status: GenericStatusEnumType.Rejected, statusInfo: { reasonCode: 'SERVER_SECURITY_CA_NOT_CONFIGURED' } } as SignCertificateResponse); - this._logger.error("Security CA not configured"); - return; - } - caCert = this._securityCaCert; - caPrivateKey = this._securityCaPrivateKey; // Verify CSR... // @ts-ignore: Unreachable code error if (!(csr as any).verify() || !this.verifyChargingStationCertificateCSR(csr, message.context.stationId)) { @@ -172,15 +182,18 @@ export class CertificatesModule extends AbstractModule { this._logger.error("Invalid CSR: {}", message.payload.csr); return; } + const clientConnection: string = await this._cache.get(message.context.stationId, CacheNamespace.Connections) as string; + caCert = this._securityCaCerts.get(clientConnection) as forge.pki.Certificate; + caPrivateKey = this._securityCaPrivateKeys.get(clientConnection) as forge.pki.rsa.PrivateKey; break; default: - this.sendCallResultWithMessage(message, { status: GenericStatusEnumType.Rejected, statusInfo: { reasonCode: 'SERVER_NOT_IMPLEMENTED', additionalInfo: certificateType } } as SignCertificateResponse); + this.sendCallResultWithMessage(message, { status: GenericStatusEnumType.Rejected, statusInfo: { reasonCode: ErrorCode.NotImplemented, additionalInfo: certificateType } } as SignCertificateResponse); this._logger.error("Unimplemented certificate type {}", certificateType); return; } - + // this.sendCallResultWithMessage(message, { status: GenericStatusEnumType.Accepted } as SignCertificateResponse); - + // Create a new certificate const cert = forge.pki.createCertificate(); cert.publicKey = csr.publicKey as forge.pki.rsa.PublicKey; diff --git a/03_Modules/Certificates/tsconfig.json b/03_Modules/Certificates/tsconfig.json index 5cebe347b..348e4d9e7 100644 --- a/03_Modules/Certificates/tsconfig.json +++ b/03_Modules/Certificates/tsconfig.json @@ -1,21 +1,23 @@ { - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": true, - "outDir": "lib", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, + "extends": "../../tsconfig.build.json", "include": [ - "src" + "src/**/*.ts", + "src/**/*.json" ], - "exclude": [ - "node_modules", - "**/__tests__/*" + "compilerOptions": { + "outDir": "./dist/", + "rootDir": "./src", + "composite": true + }, + "references": [ + { + "path": "../../00_Base" + }, + { + "path": "../../01_Data" + }, + { + "path": "../../02_Util" + } ] } \ No newline at end of file diff --git a/03_Modules/Configuration/package.json b/03_Modules/Configuration/package.json index d02bd8ac4..04d70f93d 100644 --- a/03_Modules/Configuration/package.json +++ b/03_Modules/Configuration/package.json @@ -2,20 +2,16 @@ "name": "@citrineos/configuration", "version": "1.0.0", "description": "The configuration module for OCPP v2.0.1. This module is not intended to be used directly, but rather as a dependency for other modules.", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "lib" + "dist" ], "scripts": { "prepublish": "npx eslint", - "prepare": "npm run build", - "build": "tsc", - "refresh-base": "cd ../../00_Base && npm run build && npm pack && cd ../03_Modules/Configuration && npm install ../../00_Base/citrineos-base-1.0.0.tgz", - "refresh-data": "cd ../../01_Data && npm run build && npm pack && cd ../03_Modules/Configuration && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "refresh-util": "cd ../../02_Util && npm run build && npm pack && cd ../03_Modules/Configuration && npm install ../../02_Util/citrineos-util-1.0.0.tgz", - "install-all": "npm install ../../00_Base/citrineos-base-1.0.0.tgz && npm install ../../02_Util/citrineos-util-1.0.0.tgz && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "compile": "npm run clean && tsc -p tsconfig.json", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo" }, "keywords": [ "ocpp", @@ -35,9 +31,10 @@ "typescript": "5.0.4" }, "dependencies": { - "@citrineos/base": "file:../../00_Base/citrineos-base-1.0.0.tgz", - "@citrineos/data": "file:../../01_Data/citrineos-data-1.0.0.tgz", - "@citrineos/util": "file:../../02_Util/citrineos-util-1.0.0.tgz", + "@citrineos/base": "1.0.0", + "@citrineos/data": "1.0.0", + "@citrineos/util": "1.0.0", "uuid": "^9.0.1" - } + }, + "workspace": "../../" } \ No newline at end of file diff --git a/03_Modules/Configuration/src/module/api.ts b/03_Modules/Configuration/src/module/api.ts index 7f6ceeaeb..94d354ecd 100644 --- a/03_Modules/Configuration/src/module/api.ts +++ b/03_Modules/Configuration/src/module/api.ts @@ -3,13 +3,47 @@ // // SPDX-License-Identifier: Apache 2.0 -import { Boot } from '@citrineos/data/lib/layers/sequelize'; -import { FastifyInstance, FastifyRequest } from 'fastify'; import { ILogObj, Logger } from 'tslog'; -import { IConfigurationModuleApi } from './interface'; -import { ConfigurationModule } from './module'; -import { AbstractModuleApi, AsMessageEndpoint, CallAction, SetNetworkProfileRequestSchema, SetNetworkProfileRequest, IMessageConfirmation, UpdateFirmwareRequestSchema, UpdateFirmwareRequest, ResetRequestSchema, ResetRequest, TriggerMessageRequestSchema, TriggerMessageRequest, AsDataEndpoint, Namespace, HttpMethod, BootConfigSchema, BootNotificationResponse, BootConfig } from '@citrineos/base'; -import { ChargingStationKeyQuerySchema, ChargingStationKeyQuerystring } from '@citrineos/data'; +import { + AbstractModuleApi, + AsMessageEndpoint, + CallAction, + SetNetworkProfileRequestSchema, + SetNetworkProfileRequest, + IMessageConfirmation, + ClearDisplayMessageRequestSchema, + ClearDisplayMessageRequest, + GetDisplayMessagesRequestSchema, + GetDisplayMessagesRequest, + SetDisplayMessageRequestSchema, + SetDisplayMessageRequest, + MessageInfoType, + PublishFirmwareRequestSchema, + PublishFirmwareRequest, + UnpublishFirmwareRequestSchema, + UnpublishFirmwareRequest, + UpdateFirmwareRequestSchema, + UpdateFirmwareRequest, + ResetRequestSchema, + ResetRequest, + ChangeAvailabilityRequestSchema, + ChangeAvailabilityRequest, + TriggerMessageRequestSchema, + TriggerMessageRequest, + AsDataEndpoint, + Namespace, + HttpMethod, + BootConfigSchema, + BootNotificationResponse, + BootConfig +} from "@citrineos/base"; +import { FastifyInstance, FastifyRequest } from 'fastify'; +import { ChargingStationKeyQuerySchema, ChargingStationKeyQuerystring, Boot } from "@citrineos/data"; +import { validateLanguageTag } from "@citrineos/util"; +import { IConfigurationModuleApi } from "./interface"; +import { ConfigurationModule } from "./module"; + + /** * Server API for the Configuration component. @@ -41,6 +75,71 @@ export class ConfigurationModuleApi extends AbstractModuleApi { + return this._module.sendCall(identifier, tenantId, CallAction.ClearDisplayMessage, request, callbackUrl); + } + + @AsMessageEndpoint(CallAction.GetDisplayMessages, GetDisplayMessagesRequestSchema) + getDisplayMessages( + identifier: string, + tenantId: string, + request: GetDisplayMessagesRequest, + callbackUrl?: string + ): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.GetDisplayMessages, request, callbackUrl); + } + + @AsMessageEndpoint(CallAction.SetDisplayMessage, SetDisplayMessageRequestSchema) + async setDisplayMessages( + identifier: string, + tenantId: string, + request: SetDisplayMessageRequest, + callbackUrl?: string + ): Promise { + const messageInfo = request.message as MessageInfoType; + + const languageTag = messageInfo.message.language; + if (languageTag && !validateLanguageTag(languageTag)) { + const errorMsg = 'Language shall be specified as RFC-5646 tags, example: US English is: en-US.'; + this._logger.error(errorMsg); + return { success: false, payload: errorMsg }; + } + + // According to OCPP 2.0.1, the CSMS MAY include a startTime and endTime when setting a message. + // startDateTime is from what date-time should this message be shown. If omitted: directly. + if (!messageInfo.startDateTime) { + messageInfo.startDateTime = new Date().toISOString(); + } + + return this._module.sendCall(identifier, tenantId, CallAction.SetDisplayMessage, request, callbackUrl); + } + + @AsMessageEndpoint(CallAction.PublishFirmware, PublishFirmwareRequestSchema) + publishFirmware( + identifier: string, + tenantId: string, + request: PublishFirmwareRequest, + callbackUrl?: string + ): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.PublishFirmware, request, callbackUrl); + } + + @AsMessageEndpoint(CallAction.UnpublishFirmware, UnpublishFirmwareRequestSchema) + unpublishFirmware( + identifier: string, + tenantId: string, + request: UnpublishFirmwareRequest, + callbackUrl?: string + ): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.UnpublishFirmware, request, callbackUrl); + } + @AsMessageEndpoint(CallAction.UpdateFirmware, UpdateFirmwareRequestSchema) updateFirmware( identifier: string, @@ -61,6 +160,16 @@ export class ConfigurationModuleApi extends AbstractModuleApi { + return this._module.sendCall(identifier, tenantId, CallAction.ChangeAvailability, request, callbackUrl); + } + @AsMessageEndpoint(CallAction.TriggerMessage, TriggerMessageRequestSchema) triggerMessage( identifier: string, diff --git a/03_Modules/Configuration/src/module/module.ts b/03_Modules/Configuration/src/module/module.ts index b988a8cf5..29611d4ea 100644 --- a/03_Modules/Configuration/src/module/module.ts +++ b/03_Modules/Configuration/src/module/module.ts @@ -7,6 +7,7 @@ import { AbstractModule, AsHandler, AttributeEnumType, + BOOT_STATUS, BootConfig, BootNotificationRequest, BootNotificationResponse, @@ -16,10 +17,12 @@ import { DataTransferRequest, DataTransferResponse, DataTransferStatusEnumType, + ErrorCode, EventGroup, FirmwareStatusNotificationRequest, FirmwareStatusNotificationResponse, GetBaseReportRequest, + GetDisplayMessagesResponse, HandlerProperties, HeartbeatRequest, HeartbeatResponse, @@ -29,22 +32,33 @@ import { IMessageHandler, IMessageSender, MutabilityEnumType, + NotifyDisplayMessagesRequest, + NotifyDisplayMessagesResponse, + PublishFirmwareResponse, RegistrationStatusEnumType, ReportBaseEnumType, ResetEnumType, ResetRequest, + ResetResponse, + SetDisplayMessageResponse, + SetNetworkProfileResponse, SetVariableDataType, SetVariableStatusEnumType, SetVariablesRequest, SetVariablesResponse, - SystemConfig + SystemConfig, + UnpublishFirmwareResponse, + UpdateFirmwareResponse, + ClearDisplayMessageResponse, + DisplayMessageStatusEnumType, + GetDisplayMessagesRequest, + MessageInfoType, ClearMessageStatusEnumType } from "@citrineos/base"; -import { IBootRepository, IDeviceModelRepository, sequelize } from "@citrineos/data"; +import { Boot, Component, IBootRepository, IDeviceModelRepository, IMessageInfoRepository, sequelize } from "@citrineos/data"; import { RabbitMqReceiver, RabbitMqSender, Timer } from "@citrineos/util"; import { v4 as uuidv4 } from "uuid"; import deasyncPromise from "deasync-promise"; import { ILogObj, Logger } from 'tslog'; -import { Boot } from "@citrineos/data/lib/layers/sequelize/model/Boot"; import { DeviceModelService } from "./services"; /** @@ -55,15 +69,6 @@ export class ConfigurationModule extends AbstractModule { * Constants used for cache: */ - /** - * Cache boot status is used to keep track of the overall boot process for Rejected or Pending. - * When Accepting a boot, blacklist needs to be cleared if and only if there was a previously - * Rejected or Pending boot. When starting to configure charger, i.e. sending GetBaseReport or - * SetVariables, this should only be done if configuring is not still ongoing from a previous - * BootNotificationRequest. Cache boot status mediates this behavior. - */ - public static readonly BOOT_STATUS = "boot_status"; - /** * Fields */ @@ -92,6 +97,7 @@ export class ConfigurationModule extends AbstractModule { protected _bootRepository: IBootRepository; protected _deviceModelRepository: IDeviceModelRepository; + protected _messageInfoRepository: IMessageInfoRepository; public _deviceModelService: DeviceModelService; @@ -103,6 +109,10 @@ export class ConfigurationModule extends AbstractModule { return this._deviceModelRepository; } + get messageInfoRepository(): IMessageInfoRepository { + return this._messageInfoRepository; + } + /** * Constructor */ @@ -125,10 +135,14 @@ export class ConfigurationModule extends AbstractModule { * It is used to propagate system wide logger settings and will serve as the parent logger for any sub-component logging. If no `logger` is provided, a default {@link Logger} instance is created and used. * * @param {IBootRepository} [bootRepository] - An optional parameter of type {@link IBootRepository} which represents a repository for accessing and manipulating authorization data. - * If no `bootRepository` is provided, a default {@link sequelize.BootRepository} instance is created and used. + * If no `bootRepository` is provided, a default {@link sequelize:bootRepository} instance is created and used. * * @param {IDeviceModelRepository} [deviceModelRepository] - An optional parameter of type {@link IDeviceModelRepository} which represents a repository for accessing and manipulating variable data. - * If no `deviceModelRepository` is provided, a default {@link sequelize.DeviceModelRepository} instance is created and used. + * If no `deviceModelRepository` is provided, a default {@link sequelize:deviceModelRepository} instance is created and used. + * + *@param {IMessageInfoRepository} [messageInfoRepository] - An optional parameter of type {@link messageInfoRepository} which + * represents a repository for accessing and manipulating variable data. + *If no `deviceModelRepository` is provided, a default {@link sequelize:messageInfoRepository} instance is created and used. */ constructor( config: SystemConfig, @@ -137,7 +151,8 @@ export class ConfigurationModule extends AbstractModule { handler?: IMessageHandler, logger?: Logger, bootRepository?: IBootRepository, - deviceModelRepository?: IDeviceModelRepository + deviceModelRepository?: IDeviceModelRepository, + messageInfoRepository?: IMessageInfoRepository ) { super(config, cache, handler || new RabbitMqReceiver(config, logger), sender || new RabbitMqSender(config, logger), EventGroup.Configuration, logger); @@ -150,6 +165,7 @@ export class ConfigurationModule extends AbstractModule { this._bootRepository = bootRepository || new sequelize.BootRepository(config, this._logger); this._deviceModelRepository = deviceModelRepository || new sequelize.DeviceModelRepository(config, this._logger); + this._messageInfoRepository = messageInfoRepository || new sequelize.MessageInfoRepository(config, this._logger); this._deviceModelService = new DeviceModelService(this._deviceModelRepository); @@ -205,7 +221,7 @@ export class ConfigurationModule extends AbstractModule { }; // Check cached boot status for charger. Only Pending and Rejected statuses are cached. - const cachedBootStatus = await this._cache.get(ConfigurationModule.BOOT_STATUS, stationId); + const cachedBootStatus = await this._cache.get(BOOT_STATUS, stationId); // New boot status is Accepted and cachedBootStatus exists (meaning there was a previous Rejected or Pending boot) if (bootNotificationResponse.status == RegistrationStatusEnumType.Accepted) { @@ -218,7 +234,7 @@ export class ConfigurationModule extends AbstractModule { }); await Promise.all(promises); // Remove cached boot status - this._cache.remove(ConfigurationModule.BOOT_STATUS, stationId); + this._cache.remove(BOOT_STATUS, stationId); this._logger.debug("Cached boot status removed: ", cachedBootStatus); } } else if (!cachedBootStatus) { @@ -352,7 +368,7 @@ export class ConfigurationModule extends AbstractModule { } // Handle post-response actions if (bootNotificationResponseMessageConfirmation.success) { - this._logger.debug("BootNotification response successfully sent to central system: ", bootNotificationResponseMessageConfirmation); + this._logger.debug("BootNotification response successfully sent to ocpp router: ", bootNotificationResponseMessageConfirmation); // Update charger-specific boot config with details of most recently sent BootNotificationResponse let bootConfigDbEntity: Boot | undefined = await this._bootRepository.readByKey(stationId); @@ -373,7 +389,7 @@ export class ConfigurationModule extends AbstractModule { if (bootNotificationResponse.status != RegistrationStatusEnumType.Accepted && (!cachedBootStatus || (cachedBootStatus && cachedBootStatus !== bootNotificationResponse.status))) { // Cache boot status for charger if (not accepted) and ((not already cached) or (different status from cached status)). - this._cache.set(ConfigurationModule.BOOT_STATUS, bootNotificationResponse.status, stationId); + this._cache.set(BOOT_STATUS, bootNotificationResponse.status, stationId); } // Pending status indicates configuration to do... @@ -391,16 +407,16 @@ export class ConfigurationModule extends AbstractModule { // Commenting out this line, using requestId == 0 until fixed (10/26/2023) // const requestId = Math.floor(Math.random() * ConfigurationModule.GET_BASE_REPORT_REQUEST_ID_MAX); const requestId = 0; - this._cache.set(requestId.toString(), 'ongoing', stationId, this.config.websocket.maxCachingSeconds); + this._cache.set(requestId.toString(), 'ongoing', stationId, this.config.maxCachingSeconds); const getBaseReportMessageConfirmation: IMessageConfirmation = await this.sendCall(stationId, tenantId, CallAction.GetBaseReport, { requestId: requestId, reportBase: ReportBaseEnumType.FullInventory } as GetBaseReportRequest); if (getBaseReportMessageConfirmation.success) { this._logger.debug("GetBaseReport successfully sent to charger: ", getBaseReportMessageConfirmation); // Wait for GetBaseReport to complete - let getBaseReportCacheValue = await this._cache.onChange(requestId.toString(), this.config.websocket.maxCachingSeconds, stationId); + let getBaseReportCacheValue = await this._cache.onChange(requestId.toString(), this.config.maxCachingSeconds, stationId); while (getBaseReportCacheValue == 'ongoing') { - getBaseReportCacheValue = await this._cache.onChange(requestId.toString(), this.config.websocket.maxCachingSeconds, stationId); + getBaseReportCacheValue = await this._cache.onChange(requestId.toString(), this.config.maxCachingSeconds, stationId); } if (getBaseReportCacheValue == 'complete') { @@ -429,9 +445,9 @@ export class ConfigurationModule extends AbstractModule { setVariableData.length : itemsPerMessageSetVariables; let rejectedSetVariable = false; while (setVariableData.length > 0) { - // Below pattern is preferred way of receiving CallResults in an async mannner. + // Below pattern is preferred way of receiving CallResults in an async manner. const correlationId = uuidv4(); - const cacheCallbackPromise: Promise = this._cache.onChange(correlationId, this.config.websocket.maxCachingSeconds, stationId); // x2 fudge factor for any network lag + const cacheCallbackPromise: Promise = this._cache.onChange(correlationId, this.config.maxCachingSeconds, stationId); // x2 fudge factor for any network lag this.sendCall(stationId, tenantId, CallAction.SetVariables, { setVariableData: setVariableData.slice(0, itemsPerMessageSetVariables) } as SetVariablesRequest, undefined, correlationId); setVariableData = setVariableData.slice(itemsPerMessageSetVariables); @@ -493,9 +509,37 @@ export class ConfigurationModule extends AbstractModule { }; this.sendCallResultWithMessage(message, response) - .then(messageConfirmation => this._logger.debug("Heartbeat response sent:", messageConfirmation)); + .then(messageConfirmation => { + this._logger.debug("Heartbeat response sent: ", messageConfirmation) + }); } + @AsHandler(CallAction.NotifyDisplayMessages) + protected async _handleNotifyDisplayMessages( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("NotifyDisplayMessages received: ", message, props); + + const messageInfoTypes = message.payload.messageInfo as MessageInfoType[]; + for (const messageInfoType of messageInfoTypes) { + let componentId: number | undefined; + if (messageInfoType.display) { + const component: Component = await this._deviceModelRepository.findOrCreateEvseAndComponent(messageInfoType.display, message.context.tenantId); + componentId = component.id; + } + await this._messageInfoRepository.createOrUpdateByMessageInfoTypeAndStationId(messageInfoType, message.context.stationId, componentId); + } + + // Create response + const response: NotifyDisplayMessagesResponse = { + }; + + this.sendCallResultWithMessage(message, response) + .then(messageConfirmation => { + this._logger.debug("NotifyDisplayMessages response sent: ", messageConfirmation) + }); + } @AsHandler(CallAction.FirmwareStatusNotification) protected _handleFirmwareStatusNotification( @@ -510,7 +554,25 @@ export class ConfigurationModule extends AbstractModule { const response: FirmwareStatusNotificationResponse = {}; this.sendCallResultWithMessage(message, response) - .then(messageConfirmation => this._logger.debug("FirmwareStatusNotification response sent:", messageConfirmation)); + .then(messageConfirmation => { + this._logger.debug("FirmwareStatusNotification response sent: ", messageConfirmation) + }); + } + + @AsHandler(CallAction.DataTransfer) + protected _handleDataTransfer( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("DataTransfer received:", message, props); + + // Create response + const response: DataTransferResponse = { status: DataTransferStatusEnumType.Rejected, statusInfo: { reasonCode: ErrorCode.NotImplemented } }; + + this.sendCallResultWithMessage(message, response) + .then(messageConfirmation => { + this._logger.debug("DataTransfer response sent: ", messageConfirmation) + }); } /** @@ -526,18 +588,91 @@ export class ConfigurationModule extends AbstractModule { this._logger.debug("ChangeAvailability response received:", message, props); } - @AsHandler(CallAction.DataTransfer) - protected _handleDataTransfer( - message: IMessage, + @AsHandler(CallAction.SetNetworkProfile) + protected _handleSetNetworkProfile( + message: IMessage, props?: HandlerProperties ): void { - this._logger.debug("DataTransfer received:", message, props); + this._logger.debug("SetNetworkProfile response received:", message, props); + } - // Create response - const response: DataTransferResponse = { status: DataTransferStatusEnumType.Rejected }; + @AsHandler(CallAction.GetDisplayMessages) + protected _handleGetDisplayMessages( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("GetDisplayMessages response received:", message, props); + } - this.sendCallResultWithMessage(message, response) - .then(messageConfirmation => this._logger.debug("DataTransfer response sent:", messageConfirmation)); + @AsHandler(CallAction.SetDisplayMessage) + protected async _handleSetDisplayMessage( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("SetDisplayMessage response received:", message, props); + + const status = message.payload.status as DisplayMessageStatusEnumType; + // when charger station accepts the set message info request + // we trigger a get all display messages request to update stored message info in db + if (status == DisplayMessageStatusEnumType.Accepted) { + await this._messageInfoRepository.deactivateAllByStationId(message.context.stationId) + await this.sendCall(message.context.stationId, message.context.tenantId, CallAction.GetDisplayMessages, { requestId: Math.floor(Math.random() * 1000) } as GetDisplayMessagesRequest); + } } -} + @AsHandler(CallAction.PublishFirmware) + protected _handlePublishFirmware( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("PublishFirmware response received:", message, props); + } + + @AsHandler(CallAction.UnpublishFirmware) + protected _handleUnpublishFirmware( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("UnpublishFirmware response received:", message, props); + } + + @AsHandler(CallAction.UpdateFirmware) + protected _handleUpdateFirmware( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("UpdateFirmware response received:", message, props); + } + + @AsHandler(CallAction.Reset) + protected _handleReset( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("Reset response received:", message, props); + } + + @AsHandler(CallAction.TriggerMessage) + protected _handleTriggerMessage( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("ChangeAvailability response received:", message, props); + } + + @AsHandler(CallAction.ClearDisplayMessage) + protected async _handleClearDisplayMessage( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("ClearDisplayMessage response received:", message, props); + + const status = message.payload.status as ClearMessageStatusEnumType; + // when charger station accepts the clear message info request + // we trigger a get all display messages request to update stored message info in db + if (status == ClearMessageStatusEnumType.Accepted) { + await this._messageInfoRepository.deactivateAllByStationId(message.context.stationId) + await this.sendCall(message.context.stationId, message.context.tenantId, CallAction.GetDisplayMessages, { requestId: Math.floor(Math.random() * 1000) } as GetDisplayMessagesRequest); + } + } +} \ No newline at end of file diff --git a/03_Modules/Configuration/src/module/services.ts b/03_Modules/Configuration/src/module/services.ts index d0f974446..776af9a0b 100644 --- a/03_Modules/Configuration/src/module/services.ts +++ b/03_Modules/Configuration/src/module/services.ts @@ -3,8 +3,7 @@ // SPDX-License-Identifier: Apache 2.0 import { AttributeEnumType } from "@citrineos/base"; -import { IDeviceModelRepository } from "@citrineos/data"; -import { VariableAttribute } from "@citrineos/data/lib/layers/sequelize"; +import { IDeviceModelRepository, VariableAttribute } from "@citrineos/data"; export class DeviceModelService { diff --git a/03_Modules/Configuration/tsconfig.json b/03_Modules/Configuration/tsconfig.json index 5cebe347b..348e4d9e7 100644 --- a/03_Modules/Configuration/tsconfig.json +++ b/03_Modules/Configuration/tsconfig.json @@ -1,21 +1,23 @@ { - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": true, - "outDir": "lib", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, + "extends": "../../tsconfig.build.json", "include": [ - "src" + "src/**/*.ts", + "src/**/*.json" ], - "exclude": [ - "node_modules", - "**/__tests__/*" + "compilerOptions": { + "outDir": "./dist/", + "rootDir": "./src", + "composite": true + }, + "references": [ + { + "path": "../../00_Base" + }, + { + "path": "../../01_Data" + }, + { + "path": "../../02_Util" + } ] } \ No newline at end of file diff --git a/03_Modules/EVDriver/package.json b/03_Modules/EVDriver/package.json index 617a8f6f3..b84467904 100644 --- a/03_Modules/EVDriver/package.json +++ b/03_Modules/EVDriver/package.json @@ -2,20 +2,16 @@ "name": "@citrineos/evdriver", "version": "1.0.0", "description": "The EVDriver module for OCPP v2.0.1. This module is not intended to be used directly, but rather as a dependency for other modules.", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "lib" + "dist" ], "scripts": { "prepublish": "npx eslint", - "prepare": "npm run build", - "build": "tsc", - "refresh-base": "cd ../../00_Base && npm run build && npm pack && cd ../03_Modules/EVDriver && npm install ../../00_Base/citrineos-base-1.0.0.tgz", - "refresh-data": "cd ../../01_Data && npm run build && npm pack && cd ../03_Modules/EVDriver && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "refresh-util": "cd ../../02_Util && npm run build && npm pack && cd ../03_Modules/EVDriver && npm install ../../02_Util/citrineos-util-1.0.0.tgz", - "install-all": "npm install ../../00_Base/citrineos-base-1.0.0.tgz && npm install ../../02_Util/citrineos-util-1.0.0.tgz && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "compile": "npm run clean && tsc -p tsconfig.json", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo" }, "keywords": [ "ocpp", @@ -28,9 +24,10 @@ "typescript": "^5.0.4" }, "dependencies": { - "@citrineos/base": "file:../../00_Base/citrineos-base-1.0.0.tgz", - "@citrineos/data": "file:../../01_Data/citrineos-data-1.0.0.tgz", - "@citrineos/util": "file:../../02_Util/citrineos-util-1.0.0.tgz", + "@citrineos/base": "1.0.0", + "@citrineos/data": "1.0.0", + "@citrineos/util": "1.0.0", "fastify": "^4.22.2" - } + }, + "workspace": "../../" } \ No newline at end of file diff --git a/03_Modules/EVDriver/src/module/api.ts b/03_Modules/EVDriver/src/module/api.ts index f0015698c..26309b10e 100644 --- a/03_Modules/EVDriver/src/module/api.ts +++ b/03_Modules/EVDriver/src/module/api.ts @@ -7,7 +7,7 @@ import { FastifyInstance, FastifyRequest } from 'fastify'; import { ILogObj, Logger } from 'tslog'; import { IEVDriverModuleApi } from './interface'; import { EVDriverModule } from './module'; -import { AbstractModuleApi, AsDataEndpoint, Namespace, HttpMethod, AsMessageEndpoint, CallAction, RequestStartTransactionRequestSchema, RequestStartTransactionRequest, IMessageConfirmation, RequestStopTransactionRequestSchema, RequestStopTransactionRequest, UnlockConnectorRequestSchema, UnlockConnectorRequest, ClearCacheRequestSchema, ClearCacheRequest, SendLocalListRequestSchema, SendLocalListRequest, GetLocalListVersionRequestSchema, GetLocalListVersionRequest, InstallCertificateRequestSchema, InstallCertificateRequest, GetInstalledCertificateIdsRequestSchema, GetInstalledCertificateIdsRequest, DeleteCertificateRequestSchema, DeleteCertificateRequest, AuthorizationData, AuthorizationDataSchema } from '@citrineos/base'; +import { AbstractModuleApi, AsDataEndpoint, Namespace, HttpMethod, AsMessageEndpoint, CallAction, RequestStartTransactionRequestSchema, RequestStartTransactionRequest, IMessageConfirmation, RequestStopTransactionRequestSchema, RequestStopTransactionRequest, UnlockConnectorRequestSchema, UnlockConnectorRequest, ClearCacheRequestSchema, ClearCacheRequest, SendLocalListRequestSchema, SendLocalListRequest, GetLocalListVersionRequestSchema, GetLocalListVersionRequest, AuthorizationData, AuthorizationDataSchema, CancelReservationRequest, CancelReservationRequestSchema, ReserveNowRequest, ReserveNowRequestSchema } from '@citrineos/base'; import { AuthorizationQuerySchema, AuthorizationQuerystring, AuthorizationRestrictions, AuthorizationRestrictionsSchema, ChargingStationKeyQuerySchema } from '@citrineos/data'; /** @@ -61,10 +61,20 @@ export class EVDriverModuleApi extends AbstractModuleApi impleme } @AsMessageEndpoint(CallAction.RequestStopTransaction, RequestStopTransactionRequestSchema) - requestStopTransaction(identifier: string, tenantId: string, request: RequestStopTransactionRequest, callbackUrl?: string): Promise { + async requestStopTransaction(identifier: string, tenantId: string, request: RequestStopTransactionRequest, callbackUrl?: string): Promise { return this._module.sendCall(identifier, tenantId, CallAction.RequestStopTransaction, request, callbackUrl); } + @AsMessageEndpoint(CallAction.CancelReservation, CancelReservationRequestSchema) + async cancelReservation(identifier: string, tenantId: string, request: CancelReservationRequest, callbackUrl?: string): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.CancelReservation, request, callbackUrl); + } + + @AsMessageEndpoint(CallAction.ReserveNow, ReserveNowRequestSchema) + async reserveNow(identifier: string, tenantId: string, request: ReserveNowRequest, callbackUrl?: string): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.ReserveNow, request, callbackUrl); + } + @AsMessageEndpoint(CallAction.UnlockConnector, UnlockConnectorRequestSchema) unlockConnector(identifier: string, tenantId: string, request: UnlockConnectorRequest, callbackUrl?: string): Promise { return this._module.sendCall(identifier, tenantId, CallAction.UnlockConnector, request, callbackUrl); diff --git a/03_Modules/EVDriver/src/module/module.ts b/03_Modules/EVDriver/src/module/module.ts index a11acaa5e..48da28a87 100644 --- a/03_Modules/EVDriver/src/module/module.ts +++ b/03_Modules/EVDriver/src/module/module.ts @@ -3,12 +3,40 @@ // // SPDX-License-Identifier: Apache 2.0 -import { AbstractModule, CallAction, SystemConfig, ICache, IMessageSender, IMessageHandler, EventGroup, AsHandler, IMessage, AuthorizeRequest, HandlerProperties, AuthorizeResponse, IdTokenInfoType, AdditionalInfoType, AttributeEnumType, AuthorizationStatusEnumType } from "@citrineos/base"; -import { IAuthorizationRepository, IDeviceModelRepository, sequelize } from "@citrineos/data"; -import { VariableAttribute } from "@citrineos/data/lib/layers/sequelize"; -import { RabbitMqReceiver, RabbitMqSender, Timer } from "@citrineos/util"; +import { + AbstractModule, + AdditionalInfoType, + AsHandler, + AttributeEnumType, + AuthorizationStatusEnumType, + AuthorizeRequest, + AuthorizeResponse, + CallAction, + CancelReservationResponse, + ClearCacheResponse, + EventGroup, + GetLocalListVersionResponse, + HandlerProperties, + ICache, + IdTokenInfoType, + IMessage, + IMessageHandler, + IMessageSender, + MessageContentType, + MessageFormatEnumType, + RequestStartTransactionResponse, + RequestStopTransactionResponse, + ReservationStatusUpdateRequest, + ReservationStatusUpdateResponse, + ReserveNowResponse, + SendLocalListResponse, + SystemConfig, + UnlockConnectorResponse +} from "@citrineos/base"; +import {IAuthorizationRepository, IDeviceModelRepository, ITariffRepository, sequelize, Tariff, VariableAttribute} from "@citrineos/data"; +import {RabbitMqReceiver, RabbitMqSender, Timer} from "@citrineos/util"; import deasyncPromise from "deasync-promise"; -import { ILogObj, Logger } from 'tslog'; +import {ILogObj, Logger} from 'tslog'; /** * Component that handles provisioning related messages. @@ -35,6 +63,7 @@ export class EVDriverModule extends AbstractModule { protected _authorizeRepository: IAuthorizationRepository; protected _deviceModelRepository: IDeviceModelRepository; + protected _tariffRepository: ITariffRepository; get authorizeRepository(): IAuthorizationRepository { return this._authorizeRepository; @@ -61,10 +90,17 @@ export class EVDriverModule extends AbstractModule { * It is used to propagate system wide logger settings and will serve as the parent logger for any sub-component logging. If no `logger` is provided, a default {@link Logger} instance is created and used. * * @param {IAuthorizationRepository} [authorizeRepository] - An optional parameter of type {@link IAuthorizationRepository} which represents a repository for accessing and manipulating Authorization data. - * If no `authorizeRepository` is provided, a default {@link sequelize.AuthorizationRepository} instance is created and used. + * If no `authorizeRepository` is provided, a default {@link sequelize:authorizeRepository} instance is + * created and used. * * @param {IDeviceModelRepository} [deviceModelRepository] - An optional parameter of type {@link IDeviceModelRepository} which represents a repository for accessing and manipulating variable data. - * If no `deviceModelRepository` is provided, a default {@link sequelize.DeviceModelRepository} instance is created and used. + * If no `deviceModelRepository` is provided, a default {@link sequelize:deviceModelRepository} instance is + * created and used. + * + * @param {ITariffRepository} [tariffRepository] - An optional parameter of type {@link ITariffRepository} which + * represents a repository for accessing and manipulating variable data. + * If no `deviceModelRepository` is provided, a default {@link sequelize:tariffRepository} instance is + * created and used. */ constructor( config: SystemConfig, @@ -73,7 +109,8 @@ export class EVDriverModule extends AbstractModule { handler?: IMessageHandler, logger?: Logger, authorizeRepository?: IAuthorizationRepository, - deviceModelRepository?: IDeviceModelRepository + deviceModelRepository?: IDeviceModelRepository, + tariffRepository?: ITariffRepository ) { super(config, cache, handler || new RabbitMqReceiver(config, logger), sender || new RabbitMqSender(config, logger), EventGroup.EVDriver, logger); @@ -86,6 +123,7 @@ export class EVDriverModule extends AbstractModule { this._authorizeRepository = authorizeRepository || new sequelize.AuthorizationRepository(config, logger); this._deviceModelRepository = deviceModelRepository || new sequelize.DeviceModelRepository(config, logger); + this._tariffRepository = tariffRepository || new sequelize.TariffRepository(config, logger); this._logger.info(`Initialized in ${timer.end()}ms...`); } @@ -219,8 +257,135 @@ export class EVDriverModule extends AbstractModule { // TODO determine how/if to set personalMessage } } + + if (response.idTokenInfo.status == AuthorizationStatusEnumType.Accepted) { + const tariffAvailable: VariableAttribute[] = await this._deviceModelRepository.readAllByQuery({ + stationId: message.context.stationId, + component_name: 'TariffCostCtrlr', + variable_name: 'Available', + variable_instance: 'Tariff', + type: AttributeEnumType.Actual + }); + + const displayMessageAvailable: VariableAttribute[] = await this._deviceModelRepository.readAllByQuery({ + stationId: message.context.stationId, + component_name: 'DisplayMessageCtrlr', + variable_name: 'Available', + type: AttributeEnumType.Actual + }); + + // only send the tariff information if the Charging Station supports the tariff or DisplayMessage functionality + if ((tariffAvailable.length > 0 && Boolean(tariffAvailable[0].value)) || + (displayMessageAvailable.length > 0 && Boolean(displayMessageAvailable[0].value))) { + // TODO: refactor the workaround below after tariff implementation is finalized. + const tariff: Tariff | null = await this._tariffRepository.findByStationId(message.context.stationId); + if (tariff) { + if (!response.idTokenInfo.personalMessage) { + response.idTokenInfo.personalMessage = { + format: MessageFormatEnumType.ASCII + } as MessageContentType; + } + response.idTokenInfo.personalMessage.content = `${tariff.price}/${tariff.unit}`; + } + } + } } return this.sendCallResultWithMessage(message, response) - }).then(messageConfirmation => this._logger.debug("Authorize response sent:", messageConfirmation)); + }).then(messageConfirmation => { + this._logger.debug("Authorize response sent:", messageConfirmation) + }); + } + + @AsHandler(CallAction.ReservationStatusUpdate) + protected async _handleReservationStatusUpdate( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("ReservationStatusUpdateRequest received:", message, props); + + // Create response + const response: ReservationStatusUpdateResponse = { + }; + + this.sendCallResultWithMessage(message, response) + .then(messageConfirmation => { + this._logger.debug("ReservationStatusUpdate response sent: ", messageConfirmation) + }); + } + + /** + * Handle responses + */ + + @AsHandler(CallAction.RequestStartTransaction) + protected async _handleRequestStartTransaction( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("RequestStartTransactionResponse received:", message, props); + + } + + @AsHandler(CallAction.RequestStopTransaction) + protected async _handleRequestStopTransaction( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("RequestStopTransactionResponse received:", message, props); + + } + + @AsHandler(CallAction.CancelReservation) + protected async _handleCancelReservation( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("CancelReservationResponse received:", message, props); + + } + + @AsHandler(CallAction.ReserveNow) + protected async _handleReserveNow( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("ReserveNowResponse received:", message, props); + + } + + @AsHandler(CallAction.UnlockConnector) + protected async _handleUnlockConnector( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("UnlockConnectorResponse received:", message, props); + + } + + @AsHandler(CallAction.ClearCache) + protected async _handleClearCache( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("ClearCacheResponse received:", message, props); + + } + + @AsHandler(CallAction.SendLocalList) + protected async _handleSendLocalList( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("SendLocalListResponse received:", message, props); + + } + + @AsHandler(CallAction.GetLocalListVersion) + protected async _handleGetLocalListVersion( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("GetLocalListVersionResponse received:", message, props); + } -} +} \ No newline at end of file diff --git a/03_Modules/EVDriver/tsconfig.json b/03_Modules/EVDriver/tsconfig.json index 5cebe347b..348e4d9e7 100644 --- a/03_Modules/EVDriver/tsconfig.json +++ b/03_Modules/EVDriver/tsconfig.json @@ -1,21 +1,23 @@ { - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": true, - "outDir": "lib", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, + "extends": "../../tsconfig.build.json", "include": [ - "src" + "src/**/*.ts", + "src/**/*.json" ], - "exclude": [ - "node_modules", - "**/__tests__/*" + "compilerOptions": { + "outDir": "./dist/", + "rootDir": "./src", + "composite": true + }, + "references": [ + { + "path": "../../00_Base" + }, + { + "path": "../../01_Data" + }, + { + "path": "../../02_Util" + } ] } \ No newline at end of file diff --git a/03_Modules/Monitoring/package.json b/03_Modules/Monitoring/package.json index 00f792c06..1056520b2 100644 --- a/03_Modules/Monitoring/package.json +++ b/03_Modules/Monitoring/package.json @@ -2,20 +2,16 @@ "name": "@citrineos/monitoring", "version": "1.0.0", "description": "The monitoring module for OCPP v2.0.1. This module is not intended to be used directly, but rather as a dependency for other modules.", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "lib" + "dist" ], "scripts": { "prepublish": "npx eslint", - "prepare": "npm run build", - "build": "tsc", - "refresh-base": "cd ../../00_Base && npm run build && npm pack && cd ../03_Modules/Monitoring && npm install ../../00_Base/citrineos-base-1.0.0.tgz", - "refresh-data": "cd ../../01_Data && npm run build && npm pack && cd ../03_Modules/Monitoring && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "refresh-util": "cd ../../02_Util && npm run build && npm pack && cd ../03_Modules/Monitoring && npm install ../../02_Util/citrineos-util-1.0.0.tgz", - "install-all": "npm install ../../00_Base/citrineos-base-1.0.0.tgz && npm install ../../02_Util/citrineos-util-1.0.0.tgz && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "compile": "npm run clean && tsc -p tsconfig.json", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo" }, "keywords": [ "ocpp", @@ -28,9 +24,10 @@ "typescript": "^5.0.4" }, "dependencies": { - "@citrineos/base": "file:../../00_Base/citrineos-base-1.0.0.tgz", - "@citrineos/data": "file:../../01_Data/citrineos-data-1.0.0.tgz", - "@citrineos/util": "file:../../02_Util/citrineos-util-1.0.0.tgz", + "@citrineos/base": "1.0.0", + "@citrineos/data": "1.0.0", + "@citrineos/util": "1.0.0", "fastify": "^4.22.2" - } + }, + "workspace": "../../" } \ No newline at end of file diff --git a/03_Modules/Monitoring/src/module/api.ts b/03_Modules/Monitoring/src/module/api.ts index 040ad09b3..766cc9e5b 100644 --- a/03_Modules/Monitoring/src/module/api.ts +++ b/03_Modules/Monitoring/src/module/api.ts @@ -3,17 +3,55 @@ // // SPDX-License-Identifier: Apache 2.0 -import { ILogObj, Logger } from 'tslog'; -import { IMonitoringModuleApi } from './interface'; -import { MonitoringModule } from './module'; -import { CreateOrUpdateVariableAttributeQuerySchema, CreateOrUpdateVariableAttributeQuerystring, sequelize, VariableAttributeQuerySchema, VariableAttributeQuerystring } from '@citrineos/data'; -import { AbstractModuleApi, AsMessageEndpoint, CallAction, SetVariablesRequestSchema, SetVariablesRequest, IMessageConfirmation, SetVariableDataType, GetVariablesRequestSchema, GetVariablesRequest, GetVariableDataType, AsDataEndpoint, Namespace, HttpMethod, ReportDataTypeSchema, ReportDataType, SetVariableStatusEnumType } from '@citrineos/base'; -import { FastifyInstance, FastifyRequest } from 'fastify'; +import {ILogObj, Logger} from 'tslog'; +import {IMonitoringModuleApi} from './interface'; +import {MonitoringModule} from './module'; +import { + CreateOrUpdateVariableAttributeQuerySchema, + CreateOrUpdateVariableAttributeQuerystring, + sequelize, + VariableAttributeQuerySchema, + VariableAttributeQuerystring +} from '@citrineos/data'; +import { + AbstractModuleApi, + AsDataEndpoint, + AsMessageEndpoint, + CallAction, + ClearVariableMonitoringRequest, + ClearVariableMonitoringRequestSchema, + DataEnumType, + GetVariableDataType, + GetVariablesRequest, + GetVariablesRequestSchema, + HttpMethod, + IMessageConfirmation, + MonitorEnumType, + Namespace, + ReportDataType, + ReportDataTypeSchema, + SetMonitoringBaseRequest, + SetMonitoringBaseRequestSchema, + SetMonitoringDataType, + SetMonitoringLevelRequest, + SetMonitoringLevelRequestSchema, + SetVariableDataType, + SetVariableMonitoringRequest, + SetVariableMonitoringRequestSchema, + SetVariablesRequest, + SetVariablesRequestSchema, + SetVariableStatusEnumType +} from '@citrineos/base'; +import {FastifyInstance, FastifyRequest} from 'fastify'; +import {Component, Variable} from '@citrineos/data'; +import {getBatches, getSizeOfRequest} from "@citrineos/util"; /** * Server API for the Monitoring module. */ export class MonitoringModuleApi extends AbstractModuleApi implements IMonitoringModuleApi { + private readonly _componentMonitoringCtrlr = 'MonitoringCtrlr'; + private readonly _componentDeviceDataCtrlr = 'DeviceDataCtrlr'; /** * Constructor for the class. @@ -30,6 +68,115 @@ export class MonitoringModuleApi extends AbstractModuleApi imp * Message Endpoints */ + @AsMessageEndpoint(CallAction.SetVariableMonitoring, SetVariableMonitoringRequestSchema) + async setVariableMonitoring(identifier: string, tenantId: string, request: SetVariableMonitoringRequest, callbackUrl?: string): Promise { + // if request size is bigger than BytesPerMessageSetVariableMonitoring, + // return error + let bytesPerMessageSetVariableMonitoring = await this._module._deviceModelService.getBytesPerMessageByComponentAndVariableInstanceAndStationId(this._componentMonitoringCtrlr, CallAction.SetVariableMonitoring, identifier); + const requestBytes = getSizeOfRequest(request); + if (bytesPerMessageSetVariableMonitoring && requestBytes > bytesPerMessageSetVariableMonitoring) { + let errorMsg = `The request is too big. The max size is ${bytesPerMessageSetVariableMonitoring} bytes.`; + this._logger.error(errorMsg); + return {success: false, payload: errorMsg}; + } + + let setMonitoringData = request.setMonitoringData as SetMonitoringDataType[]; + for (let i = 0; i < setMonitoringData.length; i++) { + let setMonitoringDataType: SetMonitoringDataType = setMonitoringData[i]; + this._logger.debug("Current SetMonitoringData", setMonitoringDataType); + const [component, variable] = await this._module.deviceModelRepository.findComponentAndVariable(setMonitoringDataType.component, setMonitoringDataType.variable); + this._logger.debug("Found component and variable:", component, variable); + // When the CSMS sends a SetVariableMonitoringRequest with type Delta for a Variable that is NOT of a numeric + // type, It is RECOMMENDED to use a monitorValue of 1. + if (setMonitoringDataType.type === MonitorEnumType.Delta && variable && variable.variableCharacteristics && variable.variableCharacteristics.dataType !== DataEnumType.decimal && variable.variableCharacteristics.dataType !== DataEnumType.integer) { + setMonitoringDataType.value = 1; + this._logger.debug("Updated SetMonitoringData value to 1", setMonitoringData[i]); + } + // component and variable are required for a variableMonitoring + if (component && variable) { + await this._module.variableMonitoringRepository.createOrUpdateBySetMonitoringDataTypeAndStationId(setMonitoringDataType, component.id, variable.id, identifier); + } + } + + let itemsPerMessageSetVariableMonitoring = await this._module._deviceModelService.getItemsPerMessageByComponentAndVariableInstanceAndStationId(this._componentMonitoringCtrlr, CallAction.SetVariableMonitoring, identifier); + // If ItemsPerMessageSetVariableMonitoring not set, send all variables at once + itemsPerMessageSetVariableMonitoring = itemsPerMessageSetVariableMonitoring == null ? + setMonitoringData.length : itemsPerMessageSetVariableMonitoring; + + const confirmations = []; + // TODO: Below feature doesn't work as intended due to central system behavior (cs has race condition and either sends illegal back-to-back calls or misses calls) + for (const [index, batch] of getBatches(setMonitoringData, itemsPerMessageSetVariableMonitoring).entries()) { + try { + const batchResult = await this._module.sendCall(identifier, tenantId, CallAction.SetVariableMonitoring, {setMonitoringData: batch} as SetVariableMonitoringRequest, callbackUrl); + confirmations.push({ + success: batchResult.success, + batch: `[${index}:${index + batch.length}]`, + message: `${batchResult.payload}`, + }) + } catch (error) { + confirmations.push({ + success: false, + batch: `[${index}:${index + batch.length}]`, + message: `${error}`, + }) + } + } + + // Caller should use callbackUrl to ensure request reached station, otherwise receipt is not guaranteed + return { success: true, payload: confirmations }; + } + + @AsMessageEndpoint(CallAction.ClearVariableMonitoring, ClearVariableMonitoringRequestSchema) + async clearVariableMonitoring(identifier: string, tenantId: string, request: ClearVariableMonitoringRequest, callbackUrl?: string): Promise { + this._logger.debug("ClearVariableMonitoring request received", identifier, request); + // if request size is bigger than bytesPerMessageClearVariableMonitoring, + // return error + let bytesPerMessageClearVariableMonitoring = await this._module._deviceModelService.getBytesPerMessageByComponentAndVariableInstanceAndStationId(this._componentMonitoringCtrlr, CallAction.ClearVariableMonitoring, identifier); + const requestBytes = getSizeOfRequest(request); + if (bytesPerMessageClearVariableMonitoring && requestBytes > bytesPerMessageClearVariableMonitoring) { + let errorMsg = `The request is too big. The max size is ${bytesPerMessageClearVariableMonitoring} bytes.`; + this._logger.error(errorMsg); + return {success: false, payload: errorMsg}; + } + + let ids = request.id as number[]; + let itemsPerMessageClearVariableMonitoring = await this._module._deviceModelService.getItemsPerMessageByComponentAndVariableInstanceAndStationId(this._componentMonitoringCtrlr, CallAction.ClearVariableMonitoring, identifier); + // If itemsPerMessageClearVariableMonitoring not set, send all variables at once + itemsPerMessageClearVariableMonitoring = itemsPerMessageClearVariableMonitoring == null ? + ids.length : itemsPerMessageClearVariableMonitoring; + + const confirmations = []; + // TODO: Below feature doesn't work as intended due to central system behavior (cs has race condition and either sends illegal back-to-back calls or misses calls) + for (const [index, batch] of getBatches(ids, itemsPerMessageClearVariableMonitoring).entries()) { + try { + const batchResult = await this._module.sendCall(identifier, tenantId, CallAction.ClearVariableMonitoring, {id: batch} as ClearVariableMonitoringRequest, callbackUrl); + confirmations.push({ + success: batchResult.success, + batch: `[${index}:${index + batch.length}]`, + message: `${batchResult.payload}`, + }); + } catch (error) { + confirmations.push({ + success: false, + batch: `[${index}:${index + batch.length}]`, + message: `${error}`, + }); + } + } + + return {success: true, payload: confirmations}; + } + + @AsMessageEndpoint(CallAction.SetMonitoringLevel, SetMonitoringLevelRequestSchema) + setMonitoringLevel(identifier: string, tenantId: string, request: SetMonitoringLevelRequest, callbackUrl?: string): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.SetMonitoringLevel, request, callbackUrl); + } + + @AsMessageEndpoint(CallAction.SetMonitoringBase, SetMonitoringBaseRequestSchema) + setMonitoringBase(identifier: string, tenantId: string, request: SetMonitoringBaseRequest, callbackUrl?: string): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.SetMonitoringBase, request, callbackUrl); + } + @AsMessageEndpoint(CallAction.SetVariables, SetVariablesRequestSchema) async setVariables( identifier: string, @@ -43,7 +190,7 @@ export class MonitoringModuleApi extends AbstractModuleApi imp // from SetVariablesResponse handler if variable does not exist when it attempts to save the Response's status await this._module.deviceModelRepository.createOrUpdateBySetVariablesDataAndStationId(setVariableData, identifier); - let itemsPerMessageSetVariables = await this._module._deviceModelService.getItemsPerMessageSetVariablesByStationId(identifier); + let itemsPerMessageSetVariables = await this._module._deviceModelService.getItemsPerMessageByComponentAndVariableInstanceAndStationId(this._componentDeviceDataCtrlr, CallAction.SetVariables, identifier); // If ItemsPerMessageSetVariables not set, send all variables at once itemsPerMessageSetVariables = itemsPerMessageSetVariables == null ? @@ -82,8 +229,18 @@ export class MonitoringModuleApi extends AbstractModuleApi imp request: GetVariablesRequest, callbackUrl?: string ): Promise { + // if request size is bigger than BytesPerMessageGetVariables, + // return error + let bytesPerMessageGetVariables = await this._module._deviceModelService.getBytesPerMessageByComponentAndVariableInstanceAndStationId(this._componentDeviceDataCtrlr, CallAction.GetVariables, identifier); + const requestBytes = getSizeOfRequest(request); + if (bytesPerMessageGetVariables && requestBytes > bytesPerMessageGetVariables) { + let errorMsg = `The request is too big. The max size is ${bytesPerMessageGetVariables} bytes.`; + this._logger.error(errorMsg); + return {success: false, payload: errorMsg}; + } + let getVariableData = request.getVariableData as GetVariableDataType[]; - let itemsPerMessageGetVariables = await this._module._deviceModelService.getItemsPerMessageGetVariablesByStationId(identifier); + let itemsPerMessageGetVariables = await this._module._deviceModelService.getItemsPerMessageByComponentAndVariableInstanceAndStationId(this._componentDeviceDataCtrlr, CallAction.GetVariables, identifier); // If ItemsPerMessageGetVariables not set, send all variables at once itemsPerMessageGetVariables = itemsPerMessageGetVariables == null ? @@ -92,7 +249,7 @@ export class MonitoringModuleApi extends AbstractModuleApi imp const confirmations = []; let lastVariableIndex = 0; // TODO: Below feature doesn't work as intended due to central system behavior (cs has race condition and either sends illegal back-to-back calls or misses calls) - while (getVariableData.length > 0) { + while (getVariableData.length > 0) { const batch = getVariableData.slice(0, itemsPerMessageGetVariables); try { const batchResult = await this._module.sendCall(identifier, tenantId, CallAction.GetVariables, { getVariableData: batch } as GetVariablesRequest, callbackUrl); @@ -120,15 +277,16 @@ export class MonitoringModuleApi extends AbstractModuleApi imp */ @AsDataEndpoint(Namespace.VariableAttributeType, HttpMethod.Put, CreateOrUpdateVariableAttributeQuerySchema, ReportDataTypeSchema) - putDeviceModelVariables(request: FastifyRequest<{ Body: ReportDataType, Querystring: CreateOrUpdateVariableAttributeQuerystring }>): Promise { - return this._module.deviceModelRepository.createOrUpdateDeviceModelByStationId(request.body, request.query.stationId).then(variableAttributes => { + async putDeviceModelVariables(request: FastifyRequest<{ Body: ReportDataType, Querystring: CreateOrUpdateVariableAttributeQuerystring }>): Promise { + return this._module.deviceModelRepository.createOrUpdateDeviceModelByStationId(request.body, request.query.stationId).then(async variableAttributes => { if (request.query.setOnCharger) { // value set offline, for example: manually via charger ui, or via api other than ocpp - for (const variableAttribute of variableAttributes) { + for (let variableAttribute of variableAttributes) { + variableAttribute = await variableAttribute.reload({ include: [Variable, Component] }); this._module.deviceModelRepository.updateResultByStationId({ attributeType: variableAttribute.type, attributeStatus: SetVariableStatusEnumType.Accepted, attributeStatusInfo: { reasonCode: "SetOnCharger" }, component: variableAttribute.component, variable: variableAttribute.variable - }, request.query.stationId); + }, request.query.stationId); } } return variableAttributes; diff --git a/03_Modules/Monitoring/src/module/module.ts b/03_Modules/Monitoring/src/module/module.ts index 08d406cb0..63a7ede51 100644 --- a/03_Modules/Monitoring/src/module/module.ts +++ b/03_Modules/Monitoring/src/module/module.ts @@ -3,9 +3,35 @@ // // SPDX-License-Identifier: Apache 2.0 -import { AbstractModule, CallAction, SystemConfig, ICache, IMessageSender, IMessageHandler, EventGroup, AsHandler, IMessage, NotifyEventRequest, HandlerProperties, NotifyEventResponse, GetVariablesResponse, SetVariablesResponse } from "@citrineos/base"; -import { IDeviceModelRepository, sequelize } from "@citrineos/data"; -import { PubSubReceiver, PubSubSender, Timer } from "@citrineos/util"; +import { + AbstractModule, + CallAction, + SystemConfig, + ICache, + IMessageSender, + IMessageHandler, + EventGroup, + AsHandler, + IMessage, + NotifyEventRequest, + HandlerProperties, + NotifyEventResponse, + GetVariablesResponse, + SetVariablesResponse, + ClearVariableMonitoringResponse, + GetMonitoringReportResponse, + SetMonitoringBaseResponse, + SetMonitoringLevelResponse, + SetVariableMonitoringResponse, + EventDataType, + GenericDeviceModelStatusEnumType, + StatusInfoType, + GetMonitoringReportRequest, + GenericStatusEnumType, + ClearMonitoringStatusEnumType +} from "@citrineos/base"; +import {IDeviceModelRepository, IVariableMonitoringRepository, sequelize} from "@citrineos/data"; +import { RabbitMqReceiver, RabbitMqSender, Timer } from "@citrineos/util"; import deasyncPromise from "deasync-promise"; import { ILogObj, Logger } from 'tslog'; import { DeviceModelService } from "./services"; @@ -16,24 +42,27 @@ import { DeviceModelService } from "./services"; export class MonitoringModule extends AbstractModule { protected _requests: CallAction[] = [ - CallAction.NotifyEvent, - CallAction.NotifyMonitoringReport + CallAction.NotifyEvent ]; protected _responses: CallAction[] = [ CallAction.ClearVariableMonitoring, - CallAction.GetMonitoringReport, CallAction.GetVariables, CallAction.SetMonitoringBase, CallAction.SetMonitoringLevel, + CallAction.GetMonitoringReport, CallAction.SetVariableMonitoring, CallAction.SetVariables ]; protected _deviceModelRepository: IDeviceModelRepository; + protected _variableMonitoringRepository: IVariableMonitoringRepository; get deviceModelRepository(): IDeviceModelRepository { return this._deviceModelRepository; } + get variableMonitoringRepository(): IVariableMonitoringRepository { + return this._variableMonitoringRepository; + } public _deviceModelService: DeviceModelService; @@ -55,6 +84,11 @@ export class MonitoringModule extends AbstractModule { * * @param {IDeviceModelRepository} [deviceModelRepository] - An optional parameter of type {@link IDeviceModelRepository} which represents a repository for accessing and manipulating variable data. * If no `deviceModelRepository` is provided, a default {@link sequelize.DeviceModelRepository} instance is created and used. + * + * @param {IVariableMonitoringRepository} [variableMonitoringRepository] - An optional parameter of type {@link IVariableMonitoringRepository} + * which represents a repository for accessing and manipulating variable monitoring data. + * If no `variableMonitoringRepository` is provided, a default {@link sequelize:variableMonitoringRepository} } + * instance is created and used. */ constructor( config: SystemConfig, @@ -62,9 +96,10 @@ export class MonitoringModule extends AbstractModule { sender?: IMessageSender, handler?: IMessageHandler, logger?: Logger, - deviceModelRepository?: IDeviceModelRepository + deviceModelRepository?: IDeviceModelRepository, + variableMonitoringRepository?: IVariableMonitoringRepository ) { - super(config, cache, handler || new PubSubReceiver(config, logger), sender || new PubSubSender(config, logger), EventGroup.Monitoring, logger); + super(config, cache, handler || new RabbitMqReceiver(config, logger), sender || new RabbitMqSender(config, logger), EventGroup.Monitoring, logger); const timer = new Timer(); this._logger.info(`Initializing...`); @@ -74,6 +109,7 @@ export class MonitoringModule extends AbstractModule { } this._deviceModelRepository = deviceModelRepository || new sequelize.DeviceModelRepository(config, this._logger); + this._variableMonitoringRepository = variableMonitoringRepository || new sequelize.VariableMonitoringRepository(config, this._logger); this._deviceModelService = new DeviceModelService(this._deviceModelRepository); @@ -85,30 +121,126 @@ export class MonitoringModule extends AbstractModule { */ @AsHandler(CallAction.NotifyEvent) - protected _handleNotifyEvent( + protected async _handleNotifyEvent( message: IMessage, props?: HandlerProperties - ): void { + ): Promise { this._logger.debug("NotifyEvent received:", message, props); + const events = message.payload.eventData as EventDataType[]; + for (const event of events) { + const stationId = message.context.stationId; + const [component, variable] = await this._deviceModelRepository.findComponentAndVariable(event.component, event.variable); + await this._variableMonitoringRepository.createEventDatumByComponentIdAndVariableIdAndStationId(event, component?.id, variable?.id, stationId); + } + // Create response const response: NotifyEventResponse = {}; this.sendCallResultWithMessage(message, response) - .then(messageConfirmation => this._logger.debug("NotifyEvent response sent:", messageConfirmation)); + .then(messageConfirmation => { + this._logger.debug("NotifyEvent response sent:", messageConfirmation) + }); } /** * Handle responses */ + @AsHandler(CallAction.SetVariableMonitoring) + protected async _handleSetVariableMonitoring( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("SetVariableMonitoring response received:", message, props); + + for (const setMonitoringResultType of message.payload.setMonitoringResult) { + await this._variableMonitoringRepository.updateResultByStationId(setMonitoringResultType, message.context.stationId); + } + } + + @AsHandler(CallAction.ClearVariableMonitoring) + protected async _handleClearVariableMonitoring( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("ClearVariableMonitoring response received:", message, props); + + for (const clearMonitoringResultType of message.payload.clearMonitoringResult) { + const resultStatus: ClearMonitoringStatusEnumType = clearMonitoringResultType.status; + const monitorId: number = clearMonitoringResultType.id; + + // Reject the variable monitoring if Charging Station accepts to clear or cannot find it. + if (resultStatus === ClearMonitoringStatusEnumType.Accepted || resultStatus === ClearMonitoringStatusEnumType.NotFound) { + await this._variableMonitoringRepository.rejectVariableMonitoringByIdAndStationId(CallAction.ClearVariableMonitoring, monitorId, message.context.stationId); + } else { + const statusInfo: StatusInfoType | undefined = clearMonitoringResultType.statusInfo; + this._logger.error("Failed to clear variable monitoring.", monitorId, resultStatus, statusInfo?.reasonCode, statusInfo?.additionalInfo); + } + } + } + + @AsHandler(CallAction.GetMonitoringReport) + protected _handleGetMonitoringReport( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("GetMonitoringReport response received:", message, props); + + let status: GenericDeviceModelStatusEnumType = message.payload.status; + let statusInfo: StatusInfoType | undefined = message.payload.statusInfo; + + if (status === GenericDeviceModelStatusEnumType.Rejected || status === GenericDeviceModelStatusEnumType.NotSupported) { + this._logger.error("Failed to get monitoring report.", status, statusInfo?.reasonCode, statusInfo?.additionalInfo); + } + } + + @AsHandler(CallAction.SetMonitoringLevel) + protected _handleSetMonitoringLevel( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("SetMonitoringLevel response received:", message, props); + + let status: GenericStatusEnumType = message.payload.status; + let statusInfo: StatusInfoType | undefined = message.payload.statusInfo; + if (status === GenericStatusEnumType.Rejected) { + this._logger.error("Failed to set monitoring level.", status, statusInfo?.reasonCode, statusInfo?.additionalInfo); + } + } + + @AsHandler(CallAction.SetMonitoringBase) + protected async _handleSetMonitoringBase( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("SetMonitoringBase response received:", message, props); + + let status: GenericDeviceModelStatusEnumType = message.payload.status; + let statusInfo: StatusInfoType | undefined = message.payload.statusInfo; + + if (status === GenericDeviceModelStatusEnumType.Rejected || status === GenericDeviceModelStatusEnumType.NotSupported) { + this._logger.error("Failed to set monitoring base.", status, statusInfo?.reasonCode, statusInfo?.additionalInfo); + } else { + // After setting monitoring base, variable monitorings on charger side are influenced + // To get all the latest monitoring data, we intend to mask all variable monitorings on the charger as rejected. + // Then request a GetMonitoringReport for all monitorings + const stationId: string = message.context.stationId; + await this._variableMonitoringRepository.rejectAllVariableMonitoringsByStationId(CallAction.SetVariableMonitoring, stationId); + this._logger.debug("Rejected all variable monitorings on the charger", stationId); + + // TODO: requestId is generated randomly. Think about changing it if it doesn't work on real chargers. + await this.sendCall(stationId, message.context.tenantId, CallAction.GetMonitoringReport, {requestId: Math.floor(Math.random() * 1000)} as GetMonitoringReportRequest) + } + } + @AsHandler(CallAction.GetVariables) protected async _handleGetVariables( message: IMessage, props?: HandlerProperties ): Promise { - this._logger.debug("GetVariables response received", message, props); + this._logger.debug("GetVariables response received:", message, props); this._deviceModelRepository.createOrUpdateByGetVariablesResultAndStationId(message.payload.getVariableResult, message.context.stationId); } @@ -117,7 +249,7 @@ export class MonitoringModule extends AbstractModule { message: IMessage, props?: HandlerProperties ): Promise { - this._logger.debug("SetVariables response received", message, props); + this._logger.debug("SetVariables response received:", message, props); message.payload.setVariableResult.forEach(async setVariableResultType => { this._deviceModelRepository.updateResultByStationId(setVariableResultType, message.context.stationId); diff --git a/03_Modules/Monitoring/src/module/services.ts b/03_Modules/Monitoring/src/module/services.ts index d0f974446..f84ad7f81 100644 --- a/03_Modules/Monitoring/src/module/services.ts +++ b/03_Modules/Monitoring/src/module/services.ts @@ -3,9 +3,7 @@ // SPDX-License-Identifier: Apache 2.0 import { AttributeEnumType } from "@citrineos/base"; -import { IDeviceModelRepository } from "@citrineos/data"; -import { VariableAttribute } from "@citrineos/data/lib/layers/sequelize"; - +import { IDeviceModelRepository, VariableAttribute } from "@citrineos/data"; export class DeviceModelService { protected _deviceModelRepository: IDeviceModelRepository; @@ -16,58 +14,58 @@ export class DeviceModelService { } /** - * Fetches the ItemsPerMessageSetVariables attribute from the device model. + * Fetches the ItemsPerMessage attribute from the device model. * Returns null if no such attribute exists. - * It is possible for there to be multiple ItemsPerMessageSetVariables attributes if component instances or evses + * It is possible for there to be multiple ItemsPerMessage attributes if component instances or evses * are associated with alternate options. That structure is not supported by this logic, and that * structure is a violation of Part 2 - Specification of OCPP 2.0.1. * In that case, the first attribute will be returned. * @param stationId Charging station identifier. - * @returns ItemsPerMessageSetVariables as a number or null if no such attribute exists. + * @returns ItemsPerMessage as a number or null if no such attribute exists. */ - async getItemsPerMessageSetVariablesByStationId(stationId: string): Promise { - const itemsPerMessageSetVariablesAttributes: VariableAttribute[] = await this._deviceModelRepository.readAllByQuery({ + async getItemsPerMessageByComponentAndVariableInstanceAndStationId(componentName: string, variableInstance: string, stationId: string): Promise { + const itemsPerMessageAttributes: VariableAttribute[] = await this._deviceModelRepository.readAllByQuery({ stationId: stationId, - component_name: 'DeviceDataCtrlr', + component_name: componentName, variable_name: 'ItemsPerMessage', - variable_instance: 'SetVariables', + variable_instance: variableInstance, type: AttributeEnumType.Actual }); - if (itemsPerMessageSetVariablesAttributes.length == 0) { + if (itemsPerMessageAttributes.length == 0) { return null; } else { - // It is possible for itemsPerMessageSetVariablesAttributes.length > 1 if component instances or evses + // It is possible for itemsPerMessageAttributes.length > 1 if component instances or evses // are associated with alternate options. That structure is not supported by this logic, and that // structure is a violation of Part 2 - Specification of OCPP 2.0.1. - return Number(itemsPerMessageSetVariablesAttributes[0].value); + return Number(itemsPerMessageAttributes[0].value); } } /** - * Fetches the ItemsPerMessageGetVariables attribute from the device model. + * Fetches the BytesPerMessage attribute from the device model. * Returns null if no such attribute exists. - * It is possible for there to be multiple ItemsPerMessageGetVariables attributes if component instances or evses + * It is possible for there to be multiple BytesPerMessage attributes if component instances or evses * are associated with alternate options. That structure is not supported by this logic, and that * structure is a violation of Part 2 - Specification of OCPP 2.0.1. * In that case, the first attribute will be returned. * @param stationId Charging station identifier. - * @returns ItemsPerMessageGetVariables as a number or null if no such attribute exists. + * @returns BytesPerMessage as a number or null if no such attribute exists. */ - async getItemsPerMessageGetVariablesByStationId(stationId: string): Promise { - const itemsPerMessageGetVariablesAttributes: VariableAttribute[] = await this._deviceModelRepository.readAllByQuery({ + async getBytesPerMessageByComponentAndVariableInstanceAndStationId(componentName: string, variableInstance: string, stationId: string): Promise { + const bytesPerMessageAttributes: VariableAttribute[] = await this._deviceModelRepository.readAllByQuery({ stationId: stationId, - component_name: 'DeviceDataCtrlr', - variable_name: 'ItemsPerMessage', - variable_instance: 'GetVariables', + component_name: componentName, + variable_name: 'BytesPerMessage', + variable_instance: variableInstance, type: AttributeEnumType.Actual }); - if (itemsPerMessageGetVariablesAttributes.length == 0) { + if (bytesPerMessageAttributes.length == 0) { return null; } else { - // It is possible for itemsPerMessageGetVariablesAttributes.length > 1 if component instances or evses + // It is possible for bytesPerMessageAttributes.length > 1 if component instances or evses // are associated with alternate options. That structure is not supported by this logic, and that // structure is a violation of Part 2 - Specification of OCPP 2.0.1. - return Number(itemsPerMessageGetVariablesAttributes[0].value); + return Number(bytesPerMessageAttributes[0].value); } } } \ No newline at end of file diff --git a/03_Modules/Monitoring/tsconfig.json b/03_Modules/Monitoring/tsconfig.json index 5cebe347b..348e4d9e7 100644 --- a/03_Modules/Monitoring/tsconfig.json +++ b/03_Modules/Monitoring/tsconfig.json @@ -1,21 +1,23 @@ { - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": true, - "outDir": "lib", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, + "extends": "../../tsconfig.build.json", "include": [ - "src" + "src/**/*.ts", + "src/**/*.json" ], - "exclude": [ - "node_modules", - "**/__tests__/*" + "compilerOptions": { + "outDir": "./dist/", + "rootDir": "./src", + "composite": true + }, + "references": [ + { + "path": "../../00_Base" + }, + { + "path": "../../01_Data" + }, + { + "path": "../../02_Util" + } ] } \ No newline at end of file diff --git a/03_Modules/OcppRouter/.eslintrc.js b/03_Modules/OcppRouter/.eslintrc.js new file mode 100644 index 000000000..acaf724fa --- /dev/null +++ b/03_Modules/OcppRouter/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + "env": { + "browser": false, + "es2021": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, +} diff --git a/03_Modules/OcppRouter/package.json b/03_Modules/OcppRouter/package.json new file mode 100644 index 000000000..fa8d612ba --- /dev/null +++ b/03_Modules/OcppRouter/package.json @@ -0,0 +1,37 @@ +{ + "name": "@citrineos/ocpprouter", + "version": "1.0.0", + "description": "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.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "prepublish": "npx eslint", + "compile": "npm run clean && tsc -p tsconfig.json", + "test": "echo \"Error: no test specified\" && exit 1", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo" + }, + "keywords": [ + "ocpp", + "ocpp_v201" + ], + "license": "Apache-2.0", + "devDependencies": { + "@types/deasync-promise": "^1.0.0", + "@types/node-forge": "^1.3.1", + "@types/uuid": "^9.0.1", + "eslint": "^8.48.0", + "typescript": "^5.0.4" + }, + "dependencies": { + "@citrineos/base": "1.0.0", + "@citrineos/data": "1.0.0", + "@citrineos/util": "1.0.0", + "fastify": "^4.22.2", + "node-forge": "^1.3.1", + "uuid": "^9.0.0" + }, + "workspace": "../../" +} \ No newline at end of file diff --git a/03_Modules/OcppRouter/src/index.ts b/03_Modules/OcppRouter/src/index.ts new file mode 100644 index 000000000..82f6922b8 --- /dev/null +++ b/03_Modules/OcppRouter/src/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +export { AdminApi } from './module/api'; +export { IAdminApi } from './module/interface'; +export { MessageRouterImpl } from './module/router'; \ No newline at end of file diff --git a/03_Modules/OcppRouter/src/module/api.ts b/03_Modules/OcppRouter/src/module/api.ts new file mode 100644 index 000000000..0d6d04544 --- /dev/null +++ b/03_Modules/OcppRouter/src/module/api.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { AbstractModuleApi, AsDataEndpoint, CallAction, HttpMethod, Namespace } from '@citrineos/base'; +import { FastifyInstance, FastifyRequest } from 'fastify'; +import { ILogObj, Logger } from 'tslog'; +import { IAdminApi } from './interface'; +import { MessageRouterImpl } from './router'; +import { ChargingStationKeyQuerystring, ModelKeyQuerystring, Subscription } from '@citrineos/data'; + +/** + * Admin API for the OcppRouter. + */ +export class AdminApi extends AbstractModuleApi implements IAdminApi { + + /** + * Constructs a new instance of the class. + * + * @param {MessageRouterImpl} ocppRouter - The OcppRouter module. + * @param {FastifyInstance} server - The Fastify server instance. + * @param {Logger} [logger] - The logger instance. + */ + constructor(ocppRouter: MessageRouterImpl, server: FastifyInstance, logger?: Logger) { + super(ocppRouter, server, logger); + } + + /** + * Data endpoints + */ + + // N.B.: When adding subscriptions, chargers may be connected to a different instance of Citrine. + // If this is the case, new subscriptions will not take effect until the charger reconnects. + /** + * Creates a {@link Subscription}. + * Will always create a new entity and return its id. + * + * @param {FastifyRequest<{ Body: Subscription }>} request - The request object, containing the body which is parsed as a {@link Subscription}. + * @return {Promise} The id of the created subscription. + */ + @AsDataEndpoint(Namespace.Subscription, HttpMethod.Post) + async postSubscription(request: FastifyRequest<{ Body: Subscription }>): Promise { + return this._module.subscriptionRepository.create(request.body as Subscription).then((subscription) => subscription?.id); + } + + @AsDataEndpoint(Namespace.Subscription, HttpMethod.Get) + async getSubscriptionsByChargingStation(request: FastifyRequest<{ Querystring: ChargingStationKeyQuerystring }>): Promise { + return this._module.subscriptionRepository.readAllByStationId(request.query.stationId); + } + + @AsDataEndpoint(Namespace.Subscription, HttpMethod.Delete) + async deleteSubscriptionById(request: FastifyRequest<{ Querystring: ModelKeyQuerystring }>): Promise { + return this._module.subscriptionRepository.deleteByKey(request.query.id.toString()).then(() => true); + } + + /** + * Overrides superclass method to generate the URL path based on the input {@link CallAction} and the module's endpoint prefix configuration. + * + * @param {CallAction} input - The input {@link CallAction}. + * @return {string} - The generated URL path. + */ + protected _toMessagePath(input: CallAction): string { + const endpointPrefix = '/ocpprouter'; + return super._toMessagePath(input, endpointPrefix); + } + + /** + * Overrides superclass method to generate the URL path based on the input {@link Namespace} and the module's endpoint prefix configuration. + * + * @param {CallAction} input - The input {@link Namespace}. + * @return {string} - The generated URL path. + */ + protected _toDataPath(input: Namespace): string { + const endpointPrefix = '/ocpprouter'; + return super._toDataPath(input, endpointPrefix); + } +} \ No newline at end of file diff --git a/03_Modules/OcppRouter/src/module/interface.ts b/03_Modules/OcppRouter/src/module/interface.ts new file mode 100644 index 000000000..436c81ce4 --- /dev/null +++ b/03_Modules/OcppRouter/src/module/interface.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2023 S44, LLC +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +/** + * TODO: add actual interface + * Interface for the admin api. + */ +export interface IAdminApi { +} \ No newline at end of file diff --git a/03_Modules/OcppRouter/src/module/router.ts b/03_Modules/OcppRouter/src/module/router.ts new file mode 100644 index 000000000..311213613 --- /dev/null +++ b/03_Modules/OcppRouter/src/module/router.ts @@ -0,0 +1,566 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 +/* eslint-disable */ + +import { AbstractMessageRouter, AbstractModule, BOOT_STATUS, CacheNamespace, Call, CallAction, CallError, CallResult, ErrorCode, EventGroup, ICache, IMessage, IMessageConfirmation, IMessageContext, IMessageHandler, IMessageRouter, IMessageSender, MessageOrigin, MessageState, MessageTriggerEnumType, MessageTypeId, OcppError, OcppRequest, OcppResponse, RegistrationStatusEnumType, RequestBuilder, RetryMessageError, SystemConfig, TriggerMessageRequest } from "@citrineos/base"; +import Ajv from "ajv"; +import { v4 as uuidv4 } from "uuid"; +import { ILogObj, Logger } from "tslog"; +import { ISubscriptionRepository, sequelize } from "@citrineos/data"; + +/** + * Implementation of the ocpp router + */ +export class MessageRouterImpl extends AbstractMessageRouter implements IMessageRouter { + + /** + * Fields + */ + + protected _cache: ICache; + protected _sender: IMessageSender; + protected _handler: IMessageHandler; + protected _networkHook: (identifier: string, message: string) => Promise; + + // Structure of the maps: key = identifier, value = array of callbacks + private _onConnectionCallbacks: Map) => Promise)[]> = new Map(); + private _onCloseCallbacks: Map) => Promise)[]> = new Map(); + private _onMessageCallbacks: Map) => Promise)[]> = new Map(); + private _sentMessageCallbacks: Map) => Promise)[]> = new Map(); + + public subscriptionRepository: ISubscriptionRepository; + + /** + * 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 {Function} networkHook - the network hook needed to send messages to chargers + * @param {ISubscriptionRepository} [subscriptionRepository] - the subscription repository + * @param {Logger} [logger] - the logger object (optional) + * @param {Ajv} [ajv] - the Ajv object, for message validation (optional) + */ + constructor( + config: SystemConfig, + cache: ICache, + sender: IMessageSender, + handler: IMessageHandler, + networkHook: (identifier: string, message: string) => Promise, + logger?: Logger, + ajv?: Ajv, + subscriptionRepository?: ISubscriptionRepository + ) { + super(config, cache, handler, sender, networkHook, logger, ajv); + + this._cache = cache; + this._sender = sender; + this._handler = handler; + this._networkHook = networkHook; + this.subscriptionRepository = subscriptionRepository || new sequelize.SubscriptionRepository(config, this._logger); + } + + addOnConnectionCallback(identifier: string, onConnectionCallback: (info?: Map) => Promise): void { + this._onConnectionCallbacks.has(identifier) ? + this._onConnectionCallbacks.get(identifier)!.push(onConnectionCallback) + : this._onConnectionCallbacks.set(identifier, [onConnectionCallback]); + } + + addOnCloseCallback(identifier: string, onCloseCallback: (info?: Map) => Promise): void { + this._onCloseCallbacks.has(identifier) ? + this._onCloseCallbacks.get(identifier)!.push(onCloseCallback) + : this._onCloseCallbacks.set(identifier, [onCloseCallback]); + } + + addOnMessageCallback(identifier: string, onMessageCallback: (message: string, info?: Map) => Promise): void { + this._onMessageCallbacks.has(identifier) ? + this._onMessageCallbacks.get(identifier)!.push(onMessageCallback) + : this._onMessageCallbacks.set(identifier, [onMessageCallback]); + } + + addSentMessageCallback(identifier: string, sentMessageCallback: (message: string, error: any, info?: Map) => Promise): void { + this._sentMessageCallbacks.has(identifier) ? + this._sentMessageCallbacks.get(identifier)!.push(sentMessageCallback) + : this._sentMessageCallbacks.set(identifier, [sentMessageCallback]); + } + + /** + * Interface implementation + */ + + async registerConnection(connectionIdentifier: string): Promise { + const loadConnectionCallbackSubscriptions = this._loadSubscriptionsForConnection(connectionIdentifier).then(() => { + this._onConnectionCallbacks.get(connectionIdentifier)?.forEach(async callback => { + callback(); + }); + }); + + const requestSubscription = this._handler.subscribe(connectionIdentifier, undefined, { + stationId: connectionIdentifier, + state: MessageState.Request.toString(), + origin: MessageOrigin.CentralSystem.toString() + }); + + const responseSubscription = this._handler.subscribe(connectionIdentifier, undefined, { + stationId: connectionIdentifier, + state: MessageState.Response.toString(), + origin: MessageOrigin.ChargingStation.toString() + }); + + return Promise.all([loadConnectionCallbackSubscriptions, requestSubscription, responseSubscription]).then((resolvedArray) => resolvedArray[1] && resolvedArray[2]).catch((error) => { + this._logger.error(`Error registering connection for ${connectionIdentifier}: ${error}`); + return false; + }); + } + + async deregisterConnection(connectionIdentifier: string): Promise { + this._onCloseCallbacks.get(connectionIdentifier)?.forEach(callback => { + callback(); + }); + + // TODO: ensure that all queue implementations in 02_Util only unsubscribe 1 queue per call + // ...which will require refactoring this method to unsubscribe request and response queues separately + return await this._handler.unsubscribe(connectionIdentifier) + } + + // TODO: identifier may not be unique, may require combination of tenantId and identifier. + // find way to include tenantId here + async onMessage(identifier: string, message: string): Promise { + this._onMessageCallbacks.get(identifier)?.forEach(callback => { + callback(message); + }); + let rpcMessage: any; + let messageTypeId: MessageTypeId | undefined = undefined + let messageId: string = "-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); + messageTypeId = rpcMessage[0]; + messageId = rpcMessage[1]; + } catch (error) { + throw new OcppError(messageId, ErrorCode.FormatViolation, "Invalid message format", { error: error }); + } + switch (messageTypeId) { + case MessageTypeId.Call: + this._onCall(identifier, rpcMessage as Call); + break; + case MessageTypeId.CallResult: + this._onCallResult(identifier, rpcMessage as CallResult); + break; + case MessageTypeId.CallError: + this._onCallError(identifier, rpcMessage as CallError); + break; + default: + throw new OcppError(messageId, ErrorCode.FormatViolation, "Unknown message type id: " + messageTypeId, {}); + } + return true; + } catch (error) { + this._logger.error("Error processing message:", message, error); + if (messageTypeId != MessageTypeId.CallResult && messageTypeId != MessageTypeId.CallError) { + const callError = error instanceof OcppError ? error.asCallError() + : [MessageTypeId.CallError, messageId, ErrorCode.InternalError, "Unable to process message", { error: error }]; + const rawMessage = JSON.stringify(callError, (k, v) => v ?? undefined); + this._sendMessage(identifier, rawMessage); + } + // TODO: Publish raw payload for error reporting + return false; + } + } + + /** + * 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} A promise that resolves to a boolean indicating if the call was sent successfully. + */ + async sendCall(identifier: string, tenantId: string, action: CallAction, payload: OcppRequest, correlationId = uuidv4(), origin?: MessageOrigin): Promise { + const message: Call = [MessageTypeId.Call, correlationId, action, payload]; + if (await this._sendCallIsAllowed(identifier, message)) { + if (await this._cache.setIfNotExist(identifier, `${action}:${correlationId}`, + CacheNamespace.Transactions, this._config.maxCallLengthSeconds)) { + // Intentionally removing NULL values from object for OCPP conformity + const rawMessage = JSON.stringify(message, (k, v) => v ?? undefined); + const success = await this._sendMessage(identifier, rawMessage); + return { success }; + } else { + this._logger.info("Call already in progress, throwing retry exception", identifier, message); + throw new RetryMessageError("Call already in progress"); + } + } else { + this._logger.info("RegistrationStatus Rejected, unable to send", identifier, message); + return { success: false }; + } + } + + /** + * Sends the CallResult to a charging station with given identifier. + * + * @param {string} identifier - The identifier of the charging station. + * @param {CallResult} message - The CallResult message to send. + * @return {Promise} A promise that resolves to true if the call result was sent successfully, or false otherwise. + */ + async sendCallResult(correlationId: string, identifier: string, tenantId: string, action: CallAction, payload: OcppResponse, origin?: MessageOrigin): Promise { + const message: CallResult = [MessageTypeId.CallResult, correlationId, payload]; + const cachedActionMessageId = await this._cache.get(identifier, 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?.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId + if (cachedAction === action && cachedMessageId === correlationId) { + // Intentionally removing NULL values from object for OCPP conformity + const rawMessage = JSON.stringify(message, (k, v) => v ?? undefined); + const success = await Promise.all([ + this._sendMessage(identifier, rawMessage), + this._cache.remove(identifier, 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} - A promise that resolves to true if the message was sent successfully. + */ + async sendCallError(correlationId: string, identifier: string, tenantId: string, action: CallAction, error: OcppError, origin?: MessageOrigin | undefined): Promise { + const message: CallError = error.asCallError(); + const cachedActionMessageId = await this._cache.get(identifier, 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?.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId + if (cachedMessageId === correlationId) { + // Intentionally removing NULL values from object for OCPP conformity + const rawMessage = JSON.stringify(message, (k, v) => v ?? undefined); + const success = await Promise.all([ + this._sendMessage(identifier, rawMessage), + this._cache.remove(identifier, 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(): void { + this._sender.shutdown(); + this._handler.shutdown(); + } + + /** + * Private Methods + */ + + + /** + * Loads all subscriptions for a given connection into memory + * + * @param {string} connectionIdentifier - the identifier of the connection + * @return {Promise} a promise that resolves once all subscriptions are loaded + */ + private async _loadSubscriptionsForConnection(connectionIdentifier: string) { + this._onConnectionCallbacks.set(connectionIdentifier, []); + this._onCloseCallbacks.set(connectionIdentifier, []); + this._onMessageCallbacks.set(connectionIdentifier, []); + this._sentMessageCallbacks.set(connectionIdentifier, []); + const subscriptions = await this.subscriptionRepository.readAllByStationId(connectionIdentifier); + for (const subscription of subscriptions) { + if (subscription.onConnect) { + this._onConnectionCallbacks.get(connectionIdentifier)!.push((info?: Map) => this._subscriptionCallback({ stationId: connectionIdentifier, event: 'connected', info: info }, subscription.url)); + this._logger.debug(`Added onConnect callback to ${subscription.url} for station ${connectionIdentifier}`); + } + if (subscription.onClose) { + this._onCloseCallbacks.get(connectionIdentifier)!.push((info?: Map) => this._subscriptionCallback({ stationId: connectionIdentifier, event: 'closed', info: info }, subscription.url)); + this._logger.debug(`Added onClose callback to ${subscription.url} for station ${connectionIdentifier}`); + } + if (subscription.onMessage) { + this._onMessageCallbacks.get(connectionIdentifier)!.push(async (message: string, info?: Map) => { + if (!subscription.messageRegexFilter || new RegExp(subscription.messageRegexFilter).test(message)) { + return this._subscriptionCallback({ stationId: connectionIdentifier, event: 'message', origin: MessageOrigin.ChargingStation, message: message, info: info }, subscription.url) + } else { // Ignore + return true; + } + }); + this._logger.debug(`Added onMessage callback to ${subscription.url} for station ${connectionIdentifier}`); + } + if (subscription.sentMessage) { + this._sentMessageCallbacks.get(connectionIdentifier)!.push(async (message: string, error?: any, info?: Map) => { + if (!subscription.messageRegexFilter || new RegExp(subscription.messageRegexFilter).test(message)) { + return this._subscriptionCallback({ stationId: connectionIdentifier, event: 'message', origin: MessageOrigin.CentralSystem, message: message, error: error, info: info }, subscription.url) + } else { // Ignore + return true; + } + }); + this._logger.debug(`Added sentMessage callback to ${subscription.url} for station ${connectionIdentifier}`); + } + } + } + + /** + * Sends a message to a given URL that has been subscribed to a station connection event + * + * @param {Object} requestBody - request body containing stationId, event, origin, message, error, and info + * @param {string} url - the URL to fetch data from + * @return {Promise} a Promise that resolves to a boolean indicating success + */ + private _subscriptionCallback(requestBody: { stationId: string; event: string; origin?: MessageOrigin; message?: string; error?: any; info?: Map; }, url: string): Promise { + return fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }).then(res => res.status === 200).catch(error => { + this._logger.error(error); + return false; + }); + } + + /** + * Handles an incoming Call message from a client connection. + * + * @param {string} identifier - The client identifier. + * @param {Call} message - The Call message received. + * @return {void} + */ + _onCall(identifier: string, message: Call): void { + const messageId = message[1]; + const action = message[2] as CallAction; + const payload = message[3]; + + this._onCallIsAllowed(action, identifier) + .then((isAllowed: boolean) => { + if (!isAllowed) { + throw new OcppError(messageId, ErrorCode.SecurityError, `Action ${action} not allowed`); + } else { + // Run schema validation for incoming Call message + return this._validateCall(identifier, message); + } + }).then(({ isValid, errors }) => { + if (!isValid || errors) { + throw new OcppError(messageId, ErrorCode.FormatViolation, "Invalid message format", { errors: errors }); + } + // Ensure only one call is processed at a time + return this._cache.setIfNotExist(identifier, `${action}:${messageId}`, CacheNamespace.Transactions, this._config.maxCallLengthSeconds); + }).catch(error => { + if (error instanceof OcppError) { + // TODO: identifier may not be unique, may require combination of tenantId and identifier. + // find way to include actual tenantId. + this.sendCallError(messageId, identifier, "undefined", action, error); + } + }).then(successfullySet => { + if (!successfullySet) { + throw new OcppError(messageId, ErrorCode.RpcFrameworkError, "Call already in progress", {}); + } + // Route call + return this._routeCall(identifier, message); + }).then(confirmation => { + if (!confirmation.success) { + throw new OcppError(messageId, ErrorCode.InternalError, 'Call failed', { details: confirmation.payload }); + } + }).catch(error => { + if (error instanceof OcppError) { + // TODO: identifier may not be unique, may require combination of tenantId and identifier. + // find way to include tenantId here + this.sendCallError(messageId, identifier, "undefined", action, error); + this._cache.remove(identifier, 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. + * @return {void} + */ + _onCallResult(identifier: string, message: CallResult): void { + const messageId = message[1]; + const payload = message[2]; + + this._logger.debug("Process CallResult", identifier, messageId, payload); + + this._cache.get(identifier, CacheNamespace.Transactions) + .then(cachedActionMessageId => { + this._cache.remove(identifier, CacheNamespace.Transactions); // Always remove pending call transaction + if (!cachedActionMessageId) { + throw new OcppError(messageId, ErrorCode.InternalError, "MessageId not found, call may have timed out", { "maxCallLengthSeconds": this._config.maxCallLengthSeconds }); + } + const [actionString, cachedMessageId] = cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId + if (messageId !== cachedMessageId) { + throw new OcppError(messageId, ErrorCode.InternalError, "MessageId doesn't match", { "expectedMessageId": cachedMessageId }); + } + const action: CallAction = CallAction[actionString as keyof typeof CallAction]; // Parse CallAction + return { action, ...this._validateCallResult(identifier, action, message) }; // Run schema validation for incoming CallResult message + }).then(({ action, isValid, errors }) => { + if (!isValid || errors) { + throw new OcppError(messageId, ErrorCode.FormatViolation, "Invalid message format", { errors: errors }); + } + // Route call result + return this._routeCallResult(identifier, message, action); + }).then(confirmation => { + if (!confirmation.success) { + throw new OcppError(messageId, ErrorCode.InternalError, 'CallResult failed', { details: confirmation.payload }); + } + }).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 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. + * @return {void} This function doesn't return anything. + */ + _onCallError(identifier: string, message: CallError): void { + + const messageId = message[1]; + + this._logger.debug("Process CallError", identifier, message); + + this._cache.get(identifier, CacheNamespace.Transactions) + .then(cachedActionMessageId => { + this._cache.remove(identifier, CacheNamespace.Transactions); // Always remove pending call transaction + if (!cachedActionMessageId) { + throw new OcppError(messageId, ErrorCode.InternalError, "MessageId not found, call may have timed out", { "maxCallLengthSeconds": this._config.maxCallLengthSeconds }); + } + const [actionString, cachedMessageId] = cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId + if (messageId !== cachedMessageId) { + throw new OcppError(messageId, ErrorCode.InternalError, "MessageId doesn't match", { "expectedMessageId": cachedMessageId }); + } + const action: CallAction = CallAction[actionString as keyof typeof CallAction]; // Parse CallAction + return this._routeCallError(identifier, message, action); + }).then(confirmation => { + if (!confirmation.success) { + throw new OcppError(messageId, ErrorCode.InternalError, 'CallError failed', { details: confirmation.payload }); + } + }).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} A promise that resolves to a boolean indicating if the action and identifier are allowed. + */ + private _onCallIsAllowed(action: CallAction, identifier: string): Promise { + return this._cache.exists(action, identifier).then(blacklisted => !blacklisted); + } + + private async _sendMessage(identifier: string, rawMessage: string): Promise { + try { + const success = await this._networkHook(identifier, rawMessage); + this._sentMessageCallbacks.get(identifier)?.forEach(callback => { + callback(rawMessage); + }); + return success; + } catch (error) { + this._sentMessageCallbacks.get(identifier)?.forEach(callback => { + callback(rawMessage, error); + }); + return false; + } + } + + private async _sendCallIsAllowed(identifier: string, message: Call): Promise { + const status = await this._cache.get(BOOT_STATUS, identifier); + if (status == RegistrationStatusEnumType.Rejected && + // TriggerMessage is the only message allowed to be sent during Rejected BootStatus B03.FR.08 + !(message[2] as CallAction == CallAction.TriggerMessage && (message[3] as TriggerMessageRequest).requestedMessage == MessageTriggerEnumType.BootNotification)) { + return false; + } + return true; + } + + private async _routeCall(connectionIdentifier: string, message: Call): Promise { + const messageId = message[1]; + const action = message[2] as CallAction; + const payload = message[3] as OcppRequest; + + const _message: IMessage = RequestBuilder.buildCall( + connectionIdentifier, + messageId, + '', // TODO: Add tenantId to method + action, + payload, + EventGroup.General, // TODO: Change to appropriate event group + MessageOrigin.ChargingStation + ); + + return this._sender.send(_message); + } + + private async _routeCallResult(connectionIdentifier: string, message: CallResult, action: CallAction): Promise { + const messageId = message[1]; + const payload = message[2] as OcppResponse; + + // TODO: Add tenantId to context + const context: IMessageContext = { correlationId: messageId, stationId: connectionIdentifier, tenantId: '' }; + + const _message: IMessage = { + origin: MessageOrigin.CentralSystem, + eventGroup: EventGroup.General, + action, + state: MessageState.Response, + context, + payload + }; + + return this._sender.send(_message); + } + + private async _routeCallError(connectionIdentifier: string, message: CallError, action: CallAction): Promise { + const messageId = message[1]; + const payload = new OcppError(messageId, message[2], message[3], message[4]); + + // TODO: Add tenantId to context + const context: IMessageContext = { correlationId: messageId, stationId: connectionIdentifier, tenantId: '' }; + + const _message: IMessage = { + origin: MessageOrigin.CentralSystem, + eventGroup: EventGroup.General, + action, + state: MessageState.Response, + context, + payload + }; + + // Fulfill callback for api, if needed + this._handleMessageApiCallback(_message); + + // No error routing currently done + throw new Error('Method not implemented.'); + } + + private async _handleMessageApiCallback(message: IMessage): Promise { + const url: string | null = await this._cache.get(message.context.correlationId, AbstractModule.CALLBACK_URL_CACHE_PREFIX + message.context.stationId); + if (url) { + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(message.payload) + }); + } + } +} \ No newline at end of file diff --git a/03_Modules/OcppRouter/tsconfig.json b/03_Modules/OcppRouter/tsconfig.json new file mode 100644 index 000000000..348e4d9e7 --- /dev/null +++ b/03_Modules/OcppRouter/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.build.json", + "include": [ + "src/**/*.ts", + "src/**/*.json" + ], + "compilerOptions": { + "outDir": "./dist/", + "rootDir": "./src", + "composite": true + }, + "references": [ + { + "path": "../../00_Base" + }, + { + "path": "../../01_Data" + }, + { + "path": "../../02_Util" + } + ] +} \ No newline at end of file diff --git a/03_Modules/Reporting/package.json b/03_Modules/Reporting/package.json index 7e9ad9a0a..83e488e13 100644 --- a/03_Modules/Reporting/package.json +++ b/03_Modules/Reporting/package.json @@ -2,20 +2,16 @@ "name": "@citrineos/reporting", "version": "1.0.0", "description": "The reporting module for OCPP v2.0.1. This module is not intended to be used directly, but rather as a dependency for other modules.", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "lib" + "dist" ], "scripts": { "prepublish": "npx eslint", - "prepare": "npm run build", - "build": "tsc", - "refresh-base": "cd ../../00_Base && npm run build && npm pack && cd ../03_Modules/Reporting && npm install ../../00_Base/citrineos-base-1.0.0.tgz", - "refresh-data": "cd ../../01_Data && npm run build && npm pack && cd ../03_Modules/Reporting && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "refresh-util": "cd ../../02_Util && npm run build && npm pack && cd ../03_Modules/Reporting && npm install ../../02_Util/citrineos-util-1.0.0.tgz", - "install-all": "npm install ../../00_Base/citrineos-base-1.0.0.tgz && npm install ../../02_Util/citrineos-util-1.0.0.tgz && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "compile": "npm run clean && tsc -p tsconfig.json", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo" }, "keywords": [ "ocpp", @@ -28,9 +24,10 @@ "typescript": "^5.0.4" }, "dependencies": { - "@citrineos/base": "file:../../00_Base/citrineos-base-1.0.0.tgz", - "@citrineos/data": "file:../../01_Data/citrineos-data-1.0.0.tgz", - "@citrineos/util": "file:../../02_Util/citrineos-util-1.0.0.tgz", + "@citrineos/base": "1.0.0", + "@citrineos/data": "1.0.0", + "@citrineos/util": "1.0.0", "fastify": "^4.22.2" - } + }, + "workspace": "../../" } \ No newline at end of file diff --git a/03_Modules/Reporting/src/module/api.ts b/03_Modules/Reporting/src/module/api.ts index d4b1a2cc4..338ce9104 100644 --- a/03_Modules/Reporting/src/module/api.ts +++ b/03_Modules/Reporting/src/module/api.ts @@ -4,15 +4,34 @@ // SPDX-License-Identifier: Apache 2.0 import { ILogObj, Logger } from 'tslog'; -import { AbstractModuleApi, AsMessageEndpoint, CallAction, GetMonitoringReportRequestSchema, GetMonitoringReportRequest, IMessageConfirmation, GetLogRequestSchema, GetLogRequest, CustomerInformationRequestSchema, CustomerInformationRequest, Namespace, GetBaseReportRequest, GetBaseReportRequestSchema, GetReportRequest, GetReportRequestSchema } from '@citrineos/base'; +import { + AbstractModuleApi, + AsMessageEndpoint, + CallAction, + GetMonitoringReportRequestSchema, + GetMonitoringReportRequest, + IMessageConfirmation, + GetLogRequestSchema, + GetLogRequest, + CustomerInformationRequestSchema, + CustomerInformationRequest, + Namespace, + GetBaseReportRequest, + GetBaseReportRequestSchema, + GetReportRequest, + GetReportRequestSchema, + ComponentVariableType, MonitoringCriterionEnumType +} from '@citrineos/base'; import { FastifyInstance } from 'fastify'; import { IReportingModuleApi } from './interface'; import { ReportingModule } from './module'; +import {getBatches, getSizeOfRequest} from "@citrineos/util"; /** * Server API for the Reporting module. */ export class ReportingModuleApi extends AbstractModuleApi implements IReportingModuleApi { + private readonly _componentDeviceDataCtrlr = 'DeviceDataCtrlr'; /** * Constructs a new instance of the class. @@ -42,19 +61,98 @@ export class ReportingModuleApi extends AbstractModuleApi imple } @AsMessageEndpoint(CallAction.GetReport, GetReportRequestSchema) - getCustomReport( + async getCustomReport( identifier: string, tenantId: string, request: GetReportRequest, callbackUrl?: string ): Promise { - // TODO: Consider using requestId to send NotifyReportRequests to callbackUrl - return this._module.sendCall(identifier, tenantId, CallAction.GetReport, request, callbackUrl); + // if request size is bigger than BytesPerMessageGetReport, + // return error + let bytesPerMessageGetReport = await this._module._deviceModelService.getBytesPerMessageByComponentAndVariableInstanceAndStationId(this._componentDeviceDataCtrlr, CallAction.GetReport, identifier); + const requestBytes = getSizeOfRequest(request); + if (bytesPerMessageGetReport && requestBytes > bytesPerMessageGetReport) { + let errorMsg = `The request is too big. The max size is ${bytesPerMessageGetReport} bytes.`; + this._logger.error(errorMsg); + return {success: false, payload: errorMsg}; + } + + let componentVariables = request.componentVariable as ComponentVariableType[]; + if (componentVariables.length === 0) { + return await this._module.sendCall(identifier, tenantId, CallAction.GetReport, request, callbackUrl); + } + + let itemsPerMessageGetReport = await this._module._deviceModelService.getItemsPerMessageByComponentAndVariableInstanceAndStationId(this._componentDeviceDataCtrlr, CallAction.GetReport, identifier); + // If ItemsPerMessageGetReport not set, send all variables at once + itemsPerMessageGetReport = itemsPerMessageGetReport == null ? componentVariables.length : itemsPerMessageGetReport; + + const confirmations = []; + // TODO: Below feature doesn't work as intended due to central system behavior (cs has race condition and either sends illegal back-to-back calls or misses calls) + for (const [index, batch] of getBatches(componentVariables, itemsPerMessageGetReport).entries()) { + try { + const batchResult = await this._module.sendCall(identifier, tenantId, CallAction.GetReport, { + componentVariable: batch, + componentCriteria: request.componentCriteria, + requestId: request.requestId + } as GetReportRequest, callbackUrl); + confirmations.push({ + success: batchResult.success, + batch: `[${index}:${index + batch.length}]`, + message: `${batchResult.payload}`, + }) + } catch (error) { + confirmations.push({ + success: false, + batch: `[${index}:${index + batch.length}]`, + message: `${error}`, + }) + } + } + + // TODO: Consider using requestId to send NotifyMonitoringReportRequests to callbackUrl + return {success: true, payload: confirmations}; } @AsMessageEndpoint(CallAction.GetMonitoringReport, GetMonitoringReportRequestSchema) - getMonitoringReport(identifier: string, tenantId: string, request: GetMonitoringReportRequest, callbackUrl?: string): Promise { - return this._module.sendCall(identifier, tenantId, CallAction.GetMonitoringReport, request, callbackUrl); + async getMonitoringReport(identifier: string, tenantId: string, request: GetMonitoringReportRequest, callbackUrl?: string): Promise { + let componentVariable = request.componentVariable as ComponentVariableType[]; + let monitoringCriteria = request.monitoringCriteria as MonitoringCriterionEnumType[]; + + // monitoringCriteria is empty AND componentVariables is empty. + // The set of all existing monitors is reported in one or more notifyMonitoringReportRequest messages. + if (componentVariable.length === 0 && monitoringCriteria.length === 0) { + return await this._module.sendCall(identifier, tenantId, CallAction.GetMonitoringReport, request, callbackUrl); + } + + let itemsPerMessageGetReport = await this._module._deviceModelService.getItemsPerMessageByComponentAndVariableInstanceAndStationId(this._componentDeviceDataCtrlr, CallAction.GetReport, identifier); + // If ItemsPerMessageGetReport not set, send all variables at once + itemsPerMessageGetReport = itemsPerMessageGetReport == null ? componentVariable.length : itemsPerMessageGetReport; + + const confirmations = []; + // TODO: Below feature doesn't work as intended due to central system behavior (cs has race condition and either sends illegal back-to-back calls or misses calls) + for (const [index, batch] of getBatches(componentVariable, itemsPerMessageGetReport).entries()) { + try { + const batchResult = await this._module.sendCall(identifier, tenantId, CallAction.GetMonitoringReport, { + componentVariable: batch, + monitoringCriteria: monitoringCriteria, + requestId: request.requestId + } as GetMonitoringReportRequest, callbackUrl); + confirmations.push({ + success: batchResult.success, + batch: `[${index}:${index + batch.length}]`, + message: `${batchResult.payload}`, + }) + } catch (error) { + confirmations.push({ + success: false, + batch: `[${index}:${index + batch.length}]`, + message: `${error}`, + }) + } + } + + // TODO: Consider using requestId to send NotifyMonitoringReportRequests to callbackUrl + return {success: true, payload: confirmations}; } @AsMessageEndpoint(CallAction.GetLog, GetLogRequestSchema) diff --git a/03_Modules/Reporting/src/module/module.ts b/03_Modules/Reporting/src/module/module.ts index 7050ba941..93e490304 100644 --- a/03_Modules/Reporting/src/module/module.ts +++ b/03_Modules/Reporting/src/module/module.ts @@ -3,11 +3,47 @@ // // SPDX-License-Identifier: Apache 2.0 -import { AbstractModule, CallAction, SystemConfig, ICache, IMessageSender, IMessageHandler, EventGroup, AsHandler, IMessage, NotifyReportRequest, HandlerProperties, SetVariableStatusEnumType, NotifyReportResponse, NotifyMonitoringReportRequest, NotifyMonitoringReportResponse, LogStatusNotificationRequest, LogStatusNotificationResponse, NotifyCustomerInformationRequest, NotifyCustomerInformationResponse, GetBaseReportResponse, StatusNotificationRequest, StatusNotificationResponse, SecurityEventNotificationRequest, SecurityEventNotificationResponse } from "@citrineos/base"; -import { IDeviceModelRepository, ISecurityEventRepository, sequelize } from "@citrineos/data"; -import { RabbitMqReceiver, RabbitMqSender, Timer } from "@citrineos/util"; +import { + AbstractModule, + AsHandler, + CallAction, + CustomerInformationResponse, + EventGroup, + GenericDeviceModelStatusEnumType, + GetBaseReportResponse, + GetLogResponse, + GetMonitoringReportResponse, + GetReportResponse, + HandlerProperties, + ICache, + IMessage, + IMessageHandler, + IMessageSender, + LogStatusNotificationRequest, + LogStatusNotificationResponse, + NotifyCustomerInformationRequest, + NotifyCustomerInformationResponse, + NotifyMonitoringReportRequest, + NotifyMonitoringReportResponse, + NotifyReportRequest, + NotifyReportResponse, + SecurityEventNotificationRequest, + SecurityEventNotificationResponse, + SetVariableStatusEnumType, + SystemConfig +} from "@citrineos/base"; +import { + IDeviceModelRepository, + ISecurityEventRepository, + IVariableMonitoringRepository, + sequelize +} from "@citrineos/data"; +import {Component, Variable} from "@citrineos/data"; +import {RabbitMqReceiver, RabbitMqSender, Timer} from "@citrineos/util"; import deasyncPromise from "deasync-promise"; -import { ILogObj, Logger } from 'tslog'; +import {ILogObj, Logger} from 'tslog'; +import {DeviceModelService} from "./services"; +import {StatusInfoType} from "@citrineos/base"; /** * Component that handles provisioning related messages. @@ -22,14 +58,16 @@ export class ReportingModule extends AbstractModule { CallAction.LogStatusNotification, CallAction.NotifyCustomerInformation, CallAction.NotifyReport, - CallAction.SecurityEventNotification + CallAction.SecurityEventNotification, + CallAction.NotifyMonitoringReport ]; protected _responses: CallAction[] = [ CallAction.CustomerInformation, - CallAction.GetBaseReport, CallAction.GetLog, - CallAction.GetReport + CallAction.GetReport, + CallAction.GetBaseReport, + CallAction.GetMonitoringReport ]; /** @@ -43,10 +81,14 @@ export class ReportingModule extends AbstractModule { protected _deviceModelRepository: IDeviceModelRepository; protected _securityEventRepository: ISecurityEventRepository; + protected _variableMonitoringRepository: IVariableMonitoringRepository; get deviceModelRepository(): IDeviceModelRepository { return this._deviceModelRepository; } + + public _deviceModelService: DeviceModelService; + /** * Constructor */ @@ -68,9 +110,11 @@ export class ReportingModule extends AbstractModule { * It is used to propagate system wide logger settings and will serve as the parent logger for any sub-component logging. If no `logger` is provided, a default {@link Logger} instance is created and used. * * @param {IDeviceModelRepository} [deviceModelRepository] - An optional parameter of type {@link IDeviceModelRepository} which represents a repository for accessing and manipulating variable data. - * If no `deviceModelRepository` is provided, a default {@link sequelize.DeviceModelRepository} instance is created and used. + * If no `deviceModelRepository` is provided, a default {@link sequelize:deviceModelRepository} instance is created and used. * * @param {ISecurityEventRepository} [securityEventRepository] - An optional parameter of type {@link ISecurityEventRepository} which represents a repository for accessing security event notification data. + * + * @param {IVariableMonitoringRepository} [variableMonitoringRepository] - An optional parameter of type {@link IVariableMonitoringRepository} which represents a repository for accessing and manipulating monitoring data. */ constructor( config: SystemConfig, @@ -79,9 +123,10 @@ export class ReportingModule extends AbstractModule { handler?: IMessageHandler, logger?: Logger, deviceModelRepository?: IDeviceModelRepository, - securityEventRepository?: ISecurityEventRepository + securityEventRepository?: ISecurityEventRepository, + variableMonitoringRepository?: IVariableMonitoringRepository ) { - super(config, cache, handler || new RabbitMqReceiver(config, logger, cache), sender || new RabbitMqSender(config, logger), EventGroup.Reporting, logger); + super(config, cache, handler || new RabbitMqReceiver(config, logger), sender || new RabbitMqSender(config, logger), EventGroup.Reporting, logger); const timer = new Timer(); this._logger.info(`Initializing...`); @@ -92,6 +137,8 @@ export class ReportingModule extends AbstractModule { this._deviceModelRepository = deviceModelRepository || new sequelize.DeviceModelRepository(config, this._logger); this._securityEventRepository = securityEventRepository || new sequelize.SecurityEventRepository(config, this._logger); + this._variableMonitoringRepository = variableMonitoringRepository || new sequelize.VariableMonitoringRepository(config, this._logger); + this._deviceModelService = new DeviceModelService(this._deviceModelRepository) this._logger.info(`Initialized in ${timer.end()}ms...`); } @@ -113,7 +160,9 @@ export class ReportingModule extends AbstractModule { const response: LogStatusNotificationResponse = {}; this.sendCallResultWithMessage(message, response) - .then(messageConfirmation => this._logger.debug("LogStatusNotification response sent:", messageConfirmation)); + .then(messageConfirmation => { + this._logger.debug("LogStatusNotification response sent: ", messageConfirmation) + }); } @@ -128,21 +177,31 @@ export class ReportingModule extends AbstractModule { const response: NotifyCustomerInformationResponse = {}; this.sendCallResultWithMessage(message, response) - .then(messageConfirmation => this._logger.debug("NotifyCustomerInformation response sent:", messageConfirmation)); + .then(messageConfirmation => { + this._logger.debug("NotifyCustomerInformation response sent: ", messageConfirmation) + }); } @AsHandler(CallAction.NotifyMonitoringReport) - protected _handleNotifyMonitoringReport( + protected async _handleNotifyMonitoringReport( message: IMessage, props?: HandlerProperties - ): void { + ): Promise { this._logger.debug("NotifyMonitoringReport request received:", message, props); + for (const monitorType of (message.payload.monitor ? message.payload.monitor : [])) { + const stationId: string = message.context.stationId; + const [component, variable] = await this._deviceModelRepository.findComponentAndVariable(monitorType.component, monitorType.variable); + await this._variableMonitoringRepository.createOrUpdateByMonitoringDataTypeAndStationId(monitorType, component ? component.id : null, variable ? variable.id : null, stationId); + } + // Create response const response: NotifyMonitoringReportResponse = {}; this.sendCallResultWithMessage(message, response) - .then(messageConfirmation => this._logger.debug("NotifyMonitoringReport response sent:", messageConfirmation)); + .then(messageConfirmation => { + this._logger.debug("NotifyMonitoringReport response sent: ", messageConfirmation) + }); } @AsHandler(CallAction.NotifyReport) @@ -152,18 +211,13 @@ export class ReportingModule extends AbstractModule { ): Promise { this._logger.info("NotifyReport received:", message, props); - if (!message.payload.tbc) { // Default if omitted is false - const success = await this._cache.set(message.payload.requestId.toString(), ReportingModule.GET_BASE_REPORT_COMPLETE_CACHE_VALUE, message.context.stationId); - this._logger.info("Completed", success, message.payload.requestId); - } else { // tbc (to be continued) is true - // Continue to set get base report ongoing. Will extend the timeout. - const success = await this._cache.set(message.payload.requestId.toString(), ReportingModule.GET_BASE_REPORT_ONGOING_CACHE_VALUE, message.context.stationId, this.config.websocket.maxCachingSeconds); - this._logger.info("Ongoing", success, message.payload.requestId); - } - for (const reportDataType of (message.payload.reportData ? message.payload.reportData : [])) { const variableAttributes = await this._deviceModelRepository.createOrUpdateDeviceModelByStationId(reportDataType, message.context.stationId); for (const variableAttribute of variableAttributes) { + // Reload is necessary because the upsert method used in createOrUpdateDeviceModelByStationId does not allow eager loading + await variableAttribute.reload({ + include: [Component, Variable] + }); this._deviceModelRepository.updateResultByStationId({ attributeType: variableAttribute.type, attributeStatus: SetVariableStatusEnumType.Accepted, attributeStatusInfo: { reasonCode: message.action }, @@ -172,12 +226,21 @@ export class ReportingModule extends AbstractModule { } } + if (!message.payload.tbc) { // Default if omitted is false + const success = await this._cache.set(message.payload.requestId.toString(), ReportingModule.GET_BASE_REPORT_COMPLETE_CACHE_VALUE, message.context.stationId); + this._logger.info("Completed", success, message.payload.requestId); + } else { // tbc (to be continued) is true + // Continue to set get base report ongoing. Will extend the timeout. + const success = await this._cache.set(message.payload.requestId.toString(), ReportingModule.GET_BASE_REPORT_ONGOING_CACHE_VALUE, message.context.stationId, this.config.maxCachingSeconds); + this._logger.info("Ongoing", success, message.payload.requestId); + } + // Create response const response: NotifyReportResponse = {}; this.sendCallResultWithMessage(message, response) .then((messageId) => { - this._logger.debug("NotifyReport response sent:", messageId); + this._logger.debug("NotifyReport response sent:", message, props); }); } @@ -186,7 +249,7 @@ export class ReportingModule extends AbstractModule { message: IMessage, props?: HandlerProperties ): void { - this._logger.debug("SecurityEventNotification request received", message, props); + this._logger.debug("SecurityEventNotification request received:", message, props); this._securityEventRepository.createByStationId(message.payload, message.context.stationId); this.sendCallResultWithMessage(message, {} as SecurityEventNotificationResponse); } @@ -196,10 +259,54 @@ export class ReportingModule extends AbstractModule { */ @AsHandler(CallAction.GetBaseReport) - protected _handleBaseReport( + protected _handleGetBaseReport( message: IMessage, props?: HandlerProperties ): void { - this._logger.debug("GetBaseReport response received", message, props); + this._logger.debug("GetBaseReport response received:", message, props); + } + + @AsHandler(CallAction.GetReport) + protected _handleGetReport( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("GetReport response received:", message, props); + + let status: GenericDeviceModelStatusEnumType = message.payload.status; + let statusInfo: StatusInfoType | undefined = message.payload.statusInfo; + if (status === GenericDeviceModelStatusEnumType.Rejected || status === GenericDeviceModelStatusEnumType.NotSupported) { + this._logger.error("Failed to get report.", status, statusInfo?.reasonCode, statusInfo?.additionalInfo); + } + } + + @AsHandler(CallAction.GetMonitoringReport) + protected async _handleGetMonitoringReport( + message: IMessage, + props?: HandlerProperties + ): Promise { + this._logger.debug("GetMonitoringReport response received:", message, props); + + let status: GenericDeviceModelStatusEnumType = message.payload.status; + let statusInfo: StatusInfoType | undefined = message.payload.statusInfo; + if (status === GenericDeviceModelStatusEnumType.Rejected || status === GenericDeviceModelStatusEnumType.NotSupported) { + this._logger.error("Failed to get monitoring report.", status, statusInfo?.reasonCode, statusInfo?.additionalInfo); + } + } + + @AsHandler(CallAction.GetLog) + protected _handleGetLog( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("GetLog response received:", message, props); + } + + @AsHandler(CallAction.CustomerInformation) + protected _handleCustomerInformation( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("CustomerInformation response received:", message, props); } } \ No newline at end of file diff --git a/03_Modules/Reporting/src/module/services.ts b/03_Modules/Reporting/src/module/services.ts new file mode 100644 index 000000000..bf739d593 --- /dev/null +++ b/03_Modules/Reporting/src/module/services.ts @@ -0,0 +1,67 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import {IDeviceModelRepository} from "@citrineos/data"; +import {AttributeEnumType} from "@citrineos/base"; +import {VariableAttribute} from "@citrineos/data"; + +export class DeviceModelService { + protected _deviceModelRepository: IDeviceModelRepository; + + constructor(deviceModelRepository: IDeviceModelRepository) { + this._deviceModelRepository = deviceModelRepository; + } + + /** + * Fetches the ItemsPerMessage attribute from the device model. + * Returns null if no such attribute exists. + * @param stationId Charging station identifier. + * @returns ItemsPerMessage as a number or null if no such attribute exists. + */ + async getItemsPerMessageByComponentAndVariableInstanceAndStationId(componentName: string, variableInstance: string, stationId: string): Promise { + const itemsPerMessageAttributes: VariableAttribute[] = await this._deviceModelRepository.readAllByQuery({ + stationId: stationId, + component_name: componentName, + variable_name: 'ItemsPerMessage', + variable_instance: variableInstance, + type: AttributeEnumType.Actual + }); + if (itemsPerMessageAttributes.length == 0) { + return null; + } else { + // It is possible for itemsPerMessageAttributes.length > 1 if component instances or evses + // are associated with alternate options. That structure is not supported by this logic, and that + // structure is a violation of Part 2 - Specification of OCPP 2.0.1. + return Number(itemsPerMessageAttributes[0].value); + } + } + + /** + * Fetches the BytesPerMessage attribute from the device model. + * Returns null if no such attribute exists. + * It is possible for there to be multiple BytesPerMessage attributes if component instances or evses + * are associated with alternate options. That structure is not supported by this logic, and that + * structure is a violation of Part 2 - Specification of OCPP 2.0.1. + * In that case, the first attribute will be returned. + * @param stationId Charging station identifier. + * @returns BytesPerMessage as a number or null if no such attribute exists. + */ + async getBytesPerMessageByComponentAndVariableInstanceAndStationId(componentName: string, variableInstance: string, stationId: string): Promise { + const bytesPerMessageAttributes: VariableAttribute[] = await this._deviceModelRepository.readAllByQuery({ + stationId: stationId, + component_name: componentName, + variable_name: 'BytesPerMessage', + variable_instance: variableInstance, + type: AttributeEnumType.Actual + }); + if (bytesPerMessageAttributes.length == 0) { + return null; + } else { + // It is possible for bytesPerMessageAttributes.length > 1 if component instances or evses + // are associated with alternate options. That structure is not supported by this logic, and that + // structure is a violation of Part 2 - Specification of OCPP 2.0.1. + return Number(bytesPerMessageAttributes[0].value); + } + } +} \ No newline at end of file diff --git a/03_Modules/Reporting/tsconfig.json b/03_Modules/Reporting/tsconfig.json index 5cebe347b..348e4d9e7 100644 --- a/03_Modules/Reporting/tsconfig.json +++ b/03_Modules/Reporting/tsconfig.json @@ -1,21 +1,23 @@ { - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": true, - "outDir": "lib", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, + "extends": "../../tsconfig.build.json", "include": [ - "src" + "src/**/*.ts", + "src/**/*.json" ], - "exclude": [ - "node_modules", - "**/__tests__/*" + "compilerOptions": { + "outDir": "./dist/", + "rootDir": "./src", + "composite": true + }, + "references": [ + { + "path": "../../00_Base" + }, + { + "path": "../../01_Data" + }, + { + "path": "../../02_Util" + } ] } \ No newline at end of file diff --git a/03_Modules/SmartCharging/package.json b/03_Modules/SmartCharging/package.json index 683a06fd8..972efb9ec 100644 --- a/03_Modules/SmartCharging/package.json +++ b/03_Modules/SmartCharging/package.json @@ -2,20 +2,16 @@ "name": "@citrineos/smartcharging", "version": "1.0.0", "description": "The smartcharging module for OCPP v2.0.1. This module is not intended to be used directly, but rather as a dependency for other modules.", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "lib" + "dist" ], "scripts": { "prepublish": "npx eslint", - "prepare": "npm run build", - "build": "tsc", - "refresh-base": "cd ../../00_Base && npm run build && npm pack && cd ../03_Modules/SmartCharging && npm install ../../00_Base/citrineos-base-1.0.0.tgz", - "refresh-data": "cd ../../01_Data && npm run build && npm pack && cd ../03_Modules/SmartCharging && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "refresh-util": "cd ../../02_Util && npm run build && npm pack && cd ../03_Modules/SmartCharging && npm install ../../02_Util/citrineos-util-1.0.0.tgz", - "install-all": "npm install ../../00_Base/citrineos-base-1.0.0.tgz && npm install ../../02_Util/citrineos-util-1.0.0.tgz && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "compile": "npm run clean && tsc -p tsconfig.json", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo" }, "keywords": [ "ocpp", @@ -28,9 +24,10 @@ "typescript": "^5.0.4" }, "dependencies": { - "@citrineos/base": "file:../../00_Base/citrineos-base-1.0.0.tgz", - "@citrineos/data": "file:../../01_Data/citrineos-data-1.0.0.tgz", - "@citrineos/util": "file:../../02_Util/citrineos-util-1.0.0.tgz", + "@citrineos/base": "1.0.0", + "@citrineos/data": "1.0.0", + "@citrineos/util": "1.0.0", "fastify": "^4.22.2" - } + }, + "workspace": "../../" } \ No newline at end of file diff --git a/03_Modules/SmartCharging/src/module/api.ts b/03_Modules/SmartCharging/src/module/api.ts index 8c9ff8659..913f5dca2 100644 --- a/03_Modules/SmartCharging/src/module/api.ts +++ b/03_Modules/SmartCharging/src/module/api.ts @@ -6,7 +6,7 @@ import { ILogObj, Logger } from 'tslog'; import { ISmartChargingModuleApi } from './interface'; import { SmartChargingModule } from './module'; -import { AbstractModuleApi, CallAction, Namespace } from '@citrineos/base'; +import { AbstractModuleApi, AsMessageEndpoint, CallAction, ClearChargingProfileRequest, ClearChargingProfileRequestSchema, ClearedChargingLimitRequestSchema, CustomerInformationRequest, GetChargingProfilesRequest, GetChargingProfilesRequestSchema, GetCompositeScheduleRequest, GetCompositeScheduleRequestSchema, IMessageConfirmation, Namespace, SetChargingProfileRequest, SetChargingProfileRequestSchema } from '@citrineos/base'; import { FastifyInstance } from 'fastify'; /** @@ -28,6 +28,30 @@ export class SmartChargingModuleApi extends AbstractModuleApi { + return this._module.sendCall(identifier, tenantId, CallAction.ClearChargingProfile, request, callbackUrl); + } + + @AsMessageEndpoint(CallAction.GetChargingProfiles, GetChargingProfilesRequestSchema) + getChargingProfile(identifier: string, tenantId: string, request: GetChargingProfilesRequest, callbackUrl?: string): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.GetChargingProfiles, request, callbackUrl); + } + + @AsMessageEndpoint(CallAction.SetChargingProfile, SetChargingProfileRequestSchema) + setChargingProfile(identifier: string, tenantId: string, request: SetChargingProfileRequest, callbackUrl?: string): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.SetChargingProfile, request, callbackUrl); + } + + @AsMessageEndpoint(CallAction.ClearedChargingLimit, ClearedChargingLimitRequestSchema) + clearedChargingLimit(identifier: string, tenantId: string, request: CustomerInformationRequest, callbackUrl?: string): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.ClearedChargingLimit, request, callbackUrl); + } + + @AsMessageEndpoint(CallAction.GetCompositeSchedule, GetCompositeScheduleRequestSchema) + getCompositeSchedule(identifier: string, tenantId: string, request: GetCompositeScheduleRequest, callbackUrl?: string): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.GetCompositeSchedule, request, callbackUrl); + } /** * Data endpoints diff --git a/03_Modules/SmartCharging/src/module/module.ts b/03_Modules/SmartCharging/src/module/module.ts index 9ba87e12f..76052d628 100644 --- a/03_Modules/SmartCharging/src/module/module.ts +++ b/03_Modules/SmartCharging/src/module/module.ts @@ -3,7 +3,7 @@ // // SPDX-License-Identifier: Apache 2.0 -import { AbstractModule, CallAction, SystemConfig, ICache, IMessageSender, IMessageHandler, EventGroup } from "@citrineos/base"; +import { AbstractModule, CallAction, SystemConfig, ICache, IMessageSender, IMessageHandler, EventGroup, AsHandler, HandlerProperties, IMessage, NotifyEVChargingNeedsRequest, NotifyEVChargingNeedsResponse, NotifyEVChargingNeedsStatusEnumType, NotifyEVChargingScheduleRequest, NotifyEVChargingScheduleResponse, GenericStatusEnumType, NotifyChargingLimitRequest, NotifyChargingLimitResponse, ReportChargingProfilesRequest, ReportChargingProfilesResponse, ClearChargingProfileResponse, ClearedChargingLimitResponse, GetChargingProfilesResponse, GetCompositeScheduleResponse, SetChargingProfileResponse } from "@citrineos/base"; import { RabbitMqReceiver, RabbitMqSender, Timer } from "@citrineos/util"; import deasyncPromise from "deasync-promise"; import { ILogObj, Logger } from 'tslog'; @@ -60,7 +60,7 @@ export class SmartChargingModule extends AbstractModule { handler?: IMessageHandler, logger?: Logger ) { - super(config, cache, handler || new RabbitMqReceiver(config, logger, cache), sender || new RabbitMqSender(config, logger), EventGroup.SmartCharging, logger); + super(config, cache, handler || new RabbitMqReceiver(config, logger), sender || new RabbitMqSender(config, logger), EventGroup.SmartCharging, logger); const timer = new Timer(); this._logger.info(`Initializing...`); @@ -76,7 +76,118 @@ export class SmartChargingModule extends AbstractModule { * Handle requests */ + @AsHandler(CallAction.NotifyEVChargingNeeds) + protected _handleNotifyEVChargingNeeds( + message: IMessage, + props?: HandlerProperties + ): void { + + this._logger.debug("NotifyEVChargingNeeds received:", message, props); + + // Create response + const response: NotifyEVChargingNeedsResponse = { + status: NotifyEVChargingNeedsStatusEnumType.Rejected + }; + + this.sendCallResultWithMessage(message, response) + .then(messageConfirmation => this._logger.debug("NotifyEVChargingNeeds response sent: ", messageConfirmation)); + } + + @AsHandler(CallAction.NotifyEVChargingSchedule) + protected _handleNotifyEVChargingSchedule( + message: IMessage, + props?: HandlerProperties + ): void { + + this._logger.debug("NotifyEVChargingSchedule received:", message, props); + + // Create response + const response: NotifyEVChargingScheduleResponse = { + status: GenericStatusEnumType.Accepted + }; + + this.sendCallResultWithMessage(message, response) + .then(messageConfirmation => this._logger.debug("NotifyEVChargingSchedule response sent: ", messageConfirmation)); + } + + + + @AsHandler(CallAction.NotifyChargingLimit) + protected _handleNotifyChargingLimit( + message: IMessage, + props?: HandlerProperties + ): void { + + this._logger.debug("NotifyChargingLimit received:", message, props); + + // Create response + const response: NotifyChargingLimitResponse = { + }; + + this.sendCallResultWithMessage(message, response) + .then(messageConfirmation => this._logger.debug("NotifyChargingLimit response sent: ", messageConfirmation)); + } + + + + @AsHandler(CallAction.ReportChargingProfiles) + protected _handleReportChargingProfiles( + message: IMessage, + props?: HandlerProperties + ): void { + + this._logger.debug("ReportChargingProfiles received:", message, props); + + // Create response + const response: ReportChargingProfilesResponse = { + }; + + this.sendCallResultWithMessage(message, response) + .then(messageConfirmation => this._logger.debug("ReportChargingProfiles response sent: ", messageConfirmation)); + } + + /** * Handle responses */ + + @AsHandler(CallAction.ClearChargingProfile) + protected _handleClearChargingProfile( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("ClearChargingProfile response received:", message, props); + } + + @AsHandler(CallAction.GetChargingProfiles) + protected _handleGetChargingProfiles( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("GetChargingProfiles response received:", message, props); + } + + @AsHandler(CallAction.SetChargingProfile) + protected _handleSetChargingProfile( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("SetChargingProfile response received:", message, props); + } + + @AsHandler(CallAction.ClearedChargingLimit) + protected _handleClearedChargingLimit( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("ClearedChargingLimit response received:", message, props); + } + + @AsHandler(CallAction.GetCompositeSchedule) + protected _handleGetCompositeSchedule( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("GetCompositeSchedule response received:", message, props); + } } \ No newline at end of file diff --git a/03_Modules/SmartCharging/tsconfig.json b/03_Modules/SmartCharging/tsconfig.json index 5cebe347b..348e4d9e7 100644 --- a/03_Modules/SmartCharging/tsconfig.json +++ b/03_Modules/SmartCharging/tsconfig.json @@ -1,21 +1,23 @@ { - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": true, - "outDir": "lib", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, + "extends": "../../tsconfig.build.json", "include": [ - "src" + "src/**/*.ts", + "src/**/*.json" ], - "exclude": [ - "node_modules", - "**/__tests__/*" + "compilerOptions": { + "outDir": "./dist/", + "rootDir": "./src", + "composite": true + }, + "references": [ + { + "path": "../../00_Base" + }, + { + "path": "../../01_Data" + }, + { + "path": "../../02_Util" + } ] } \ No newline at end of file diff --git a/03_Modules/Transactions/package.json b/03_Modules/Transactions/package.json index ae589eac5..098fc00dc 100644 --- a/03_Modules/Transactions/package.json +++ b/03_Modules/Transactions/package.json @@ -2,20 +2,16 @@ "name": "@citrineos/transactions", "version": "1.0.0", "description": "The transactions module for OCPP v2.0.1. This module is not intended to be used directly, but rather as a dependency for other modules.", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "files": [ - "lib" + "dist" ], "scripts": { "prepublish": "npx eslint", - "prepare": "npm run build", - "build": "tsc", - "refresh-base": "cd ../../00_Base && npm run build && npm pack && cd ../03_Modules/Transactions && npm install ../../00_Base/citrineos-base-1.0.0.tgz", - "refresh-data": "cd ../../01_Data && npm run build && npm pack && cd ../03_Modules/Transactions && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "refresh-util": "cd ../../02_Util && npm run build && npm pack && cd ../03_Modules/Transactions && npm install ../../02_Util/citrineos-util-1.0.0.tgz", - "install-all": "npm install ../../00_Base/citrineos-base-1.0.0.tgz && npm install ../../02_Util/citrineos-util-1.0.0.tgz && npm install ../../01_Data/citrineos-data-1.0.0.tgz", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "compile": "npm run clean && tsc -p tsconfig.json", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo" }, "keywords": [ "ocpp", @@ -28,9 +24,10 @@ "typescript": "^5.0.4" }, "dependencies": { - "@citrineos/base": "file:../../00_Base/citrineos-base-1.0.0.tgz", - "@citrineos/data": "file:../../01_Data/citrineos-data-1.0.0.tgz", - "@citrineos/util": "file:../../02_Util/citrineos-util-1.0.0.tgz", + "@citrineos/base": "1.0.0", + "@citrineos/data": "1.0.0", + "@citrineos/util": "1.0.0", "fastify": "^4.22.2" - } + }, + "workspace": "../../" } \ No newline at end of file diff --git a/03_Modules/Transactions/src/module/api.ts b/03_Modules/Transactions/src/module/api.ts index bae66a388..d92ee6070 100644 --- a/03_Modules/Transactions/src/module/api.ts +++ b/03_Modules/Transactions/src/module/api.ts @@ -3,11 +3,11 @@ // // SPDX-License-Identifier: Apache 2.0 -import { TransactionEventQuerySchema, TransactionEventQuerystring } from "@citrineos/data"; +import { TransactionEventQuerySchema, TransactionEventQuerystring,Tariff, TariffSchema, CreateOrUpdateTariffQuerySchema, CreateOrUpdateTariffQueryString, TariffQuerySchema, TariffQueryString } from "@citrineos/data"; import { ILogObj, Logger } from 'tslog'; import { ITransactionsModuleApi } from './interface'; import { TransactionsModule } from './module'; -import { AbstractModuleApi, AsDataEndpoint, Namespace, HttpMethod, TransactionEventRequest, TransactionType, AsMessageEndpoint, CallAction, GetTransactionStatusRequestSchema, GetTransactionStatusRequest, IMessageConfirmation } from "@citrineos/base"; +import { AbstractModuleApi, AsDataEndpoint, Namespace, CostUpdatedRequest, CostUpdatedRequestSchema, HttpMethod, TransactionEventRequest, TransactionType, AsMessageEndpoint, CallAction, GetTransactionStatusRequestSchema, GetTransactionStatusRequest, IMessageConfirmation } from "@citrineos/base"; import { FastifyInstance, FastifyRequest } from "fastify"; /** @@ -29,6 +29,10 @@ export class TransactionsModuleApi extends AbstractModuleApi /** * Message Endpoint Methods */ + @AsMessageEndpoint(CallAction.CostUpdated, CostUpdatedRequestSchema) + async costUpdated(identifier: string, tenantId: string, request: CostUpdatedRequest, callbackUrl?: string): Promise { + return this._module.sendCall(identifier, tenantId, CallAction.CostUpdated, request, callbackUrl); + } @AsMessageEndpoint(CallAction.GetTransactionStatus, GetTransactionStatusRequestSchema) getTransactionStatus(identifier: string, tenantId: string, request: GetTransactionStatusRequest, callbackUrl?: string): Promise { @@ -52,6 +56,22 @@ export class TransactionsModuleApi extends AbstractModuleApi // TODO: Determine how to implement readAllTransactionsByStationIdAndChargingStates as a GET... // TODO: Determine how to implement existsActiveTransactionByIdToken as a GET... + @AsDataEndpoint(Namespace.Tariff, HttpMethod.Put, CreateOrUpdateTariffQuerySchema, TariffSchema) + putTariff(request: FastifyRequest<{ Body: Tariff, Querystring: CreateOrUpdateTariffQueryString }>): Promise { + return this._module.tariffRepository.createOrUpdateTariff(request.body); + } + + @AsDataEndpoint(Namespace.Tariff, HttpMethod.Get, TariffQuerySchema) + getTariffs(request: FastifyRequest<{ Querystring: TariffQueryString }>): Promise { + return this._module.tariffRepository.readAllByQuery(request.query); + } + + @AsDataEndpoint(Namespace.Tariff, HttpMethod.Delete, TariffQuerySchema) + deleteTariffs(request: FastifyRequest<{ Querystring: TariffQueryString }>): Promise { + return this._module.tariffRepository.deleteAllByQuery(request.query) + .then(deletedCount => deletedCount.toString() + " rows successfully deleted from " + Namespace.Tariff); + } + /** * Overrides superclass method to generate the URL path based on the input {@link CallAction} and the module's endpoint prefix configuration. * diff --git a/03_Modules/Transactions/src/module/module.ts b/03_Modules/Transactions/src/module/module.ts index 99f574e13..ce61cfd01 100644 --- a/03_Modules/Transactions/src/module/module.ts +++ b/03_Modules/Transactions/src/module/module.ts @@ -3,9 +3,41 @@ // // SPDX-License-Identifier: Apache 2.0 -import { AbstractModule, CallAction, SystemConfig, ICache, IMessageSender, IMessageHandler, EventGroup, AsHandler, IMessage, TransactionEventRequest, HandlerProperties, TransactionEventResponse, AuthorizationStatusEnumType, IdTokenInfoType, AdditionalInfoType, TransactionEventEnumType, MeterValuesRequest, MeterValuesResponse, StatusNotificationRequest, StatusNotificationResponse } from "@citrineos/base"; -import { IAuthorizationRepository, ITransactionEventRepository, sequelize } from "@citrineos/data"; -import { PubSubReceiver, PubSubSender, Timer } from "@citrineos/util"; +import { + AbstractModule, + AdditionalInfoType, + AsHandler, AttributeEnumType, + AuthorizationStatusEnumType, + CallAction, CostUpdatedRequest, + CostUpdatedResponse, + EventGroup, + GetTransactionStatusResponse, + HandlerProperties, + ICache, + IdTokenInfoType, + IMessage, + IMessageHandler, + IMessageSender, MeasurandEnumType, + MeterValuesRequest, + MeterValuesResponse, ReadingContextEnumType, SampledValueType, + StatusNotificationRequest, + StatusNotificationResponse, + SystemConfig, + TransactionEventEnumType, + TransactionEventRequest, + TransactionEventResponse +} from "@citrineos/base"; +import { + IAuthorizationRepository, + IDeviceModelRepository, ITariffRepository, + ITransactionEventRepository, + MeterValue, + sequelize, + Tariff, + Transaction, + VariableAttribute +} from "@citrineos/data"; +import { RabbitMqReceiver, RabbitMqSender, Timer} from "@citrineos/util"; import deasyncPromise from "deasync-promise"; import { ILogObj, Logger } from 'tslog'; @@ -20,12 +52,17 @@ export class TransactionsModule extends AbstractModule { CallAction.TransactionEvent ]; protected _responses: CallAction[] = [ - CallAction.CostUpdate, + CallAction.CostUpdated, CallAction.GetTransactionStatus ]; + private readonly _sendCostUpdatedOnMeterValue: boolean | undefined; + private readonly _costUpdatedInterval: number | undefined; + protected _transactionEventRepository: ITransactionEventRepository; protected _authorizeRepository: IAuthorizationRepository; + protected _deviceModelRepository: IDeviceModelRepository; + protected _tariffRepository: ITariffRepository; get transactionEventRepository(): ITransactionEventRepository { return this._transactionEventRepository; @@ -35,6 +72,14 @@ export class TransactionsModule extends AbstractModule { return this._authorizeRepository; } + get deviceModelRepository(): IDeviceModelRepository { + return this._deviceModelRepository; + } + + get tariffRepository(): ITariffRepository { + return this._tariffRepository; + } + /** * This is the constructor function that initializes the {@link TransactionModule}. * @@ -52,10 +97,21 @@ export class TransactionsModule extends AbstractModule { * It is used to propagate system wide logger settings and will serve as the parent logger for any sub-component logging. If no `logger` is provided, a default {@link Logger} instance is created and used. * * @param {ITransactionEventRepository} [transactionEventRepository] - An optional parameter of type {@link ITransactionEventRepository} which represents a repository for accessing and manipulating authorization data. - * If no `transactionEventRepository` is provided, a default {@link sequelize.TransactionEventRepository} instance is created and used. + * If no `transactionEventRepository` is provided, a default {@link sequelize:transactionEventRepository} instance + * is created and used. * * @param {IAuthorizationRepository} [authorizeRepository] - An optional parameter of type {@link IAuthorizationRepository} which represents a repository for accessing and manipulating variable data. - * If no `authorizeRepository` is provided, a default {@link sequelize.AuthorizationRepository} instance is created and used. + * If no `authorizeRepository` is provided, a default {@link sequelize:authorizeRepository} instance is + * created and used. + * + * @param {IDeviceModelRepository} [deviceModelRepository] - An optional parameter of type {@link IDeviceModelRepository} which represents a repository for accessing and manipulating variable data. + * If no `deviceModelRepository` is provided, a default {@link sequelize:deviceModelRepository} instance is + * created and used. + * + * @param {ITariffRepository} [tariffRepository] - An optional parameter of type {@link ITariffRepository} which + * represents a repository for accessing and manipulating variable data. + * If no `deviceModelRepository` is provided, a default {@link sequelize:tariffRepository} instance is + * created and used. */ constructor( config: SystemConfig, @@ -64,9 +120,11 @@ export class TransactionsModule extends AbstractModule { handler?: IMessageHandler, logger?: Logger, transactionEventRepository?: ITransactionEventRepository, - authorizeRepository?: IAuthorizationRepository + authorizeRepository?: IAuthorizationRepository, + deviceModelRepository?: IDeviceModelRepository, + tariffRepository?: ITariffRepository ) { - super(config, cache, handler || new PubSubReceiver(config, logger), sender || new PubSubSender(config, logger), EventGroup.Transactions, logger); + super(config, cache, handler || new RabbitMqReceiver(config, logger), sender || new RabbitMqSender(config, logger), EventGroup.Transactions, logger); const timer = new Timer(); this._logger.info(`Initializing...`); @@ -77,6 +135,11 @@ export class TransactionsModule extends AbstractModule { this._transactionEventRepository = transactionEventRepository || new sequelize.TransactionEventRepository(config, logger); this._authorizeRepository = authorizeRepository || new sequelize.AuthorizationRepository(config, logger); + this._deviceModelRepository = deviceModelRepository || new sequelize.DeviceModelRepository(config, logger); + this._tariffRepository = tariffRepository || new sequelize.TariffRepository(config, logger); + + this._sendCostUpdatedOnMeterValue = config.modules.transactions.sendCostUpdatedOnMeterValue; + this._costUpdatedInterval = config.modules.transactions.costUpdatedInterval; this._logger.info(`Initialized in ${timer.end()}ms...`); } @@ -91,10 +154,12 @@ export class TransactionsModule extends AbstractModule { props?: HandlerProperties ): Promise { this._logger.debug("Transaction event received:", message, props); + const stationId: string = message.context.stationId; - await this._transactionEventRepository.createOrUpdateTransactionByTransactionEventAndStationId(message.payload, message.context.stationId); + await this._transactionEventRepository.createOrUpdateTransactionByTransactionEventAndStationId(message.payload, stationId); const transactionEvent = message.payload; + const transactionId = transactionEvent.transactionInfo.transactionId; if (transactionEvent.idToken) { this._authorizeRepository.readByQuery({ ...transactionEvent.idToken }).then(authorization => { const response: TransactionEventResponse = { @@ -155,9 +220,14 @@ export class TransactionsModule extends AbstractModule { return response; }).then(transactionEventResponse => { if (transactionEvent.eventType == TransactionEventEnumType.Started && transactionEventResponse - && transactionEventResponse.idTokenInfo?.status == AuthorizationStatusEnumType.Accepted && transactionEvent.idToken) { + && transactionEventResponse.idTokenInfo?.status == AuthorizationStatusEnumType.Accepted && transactionEvent.idToken) { + + if(this._costUpdatedInterval) { + this._updateCost(stationId, transactionId, this._costUpdatedInterval, message.context.tenantId) + } + // Check for ConcurrentTx - return this._transactionEventRepository.readAllActiveTransactionByIdToken(transactionEvent.idToken).then(activeTransactions => { + return this._transactionEventRepository.readAllActiveTransactionsByIdToken(transactionEvent.idToken).then(activeTransactions => { // Transaction in this TransactionEventRequest has already been saved, so there should only be 1 active transaction for idToken if (activeTransactions.length > 1) { const groupIdToken = transactionEventResponse.idTokenInfo?.groupIdToken; @@ -173,14 +243,47 @@ export class TransactionsModule extends AbstractModule { return transactionEventResponse; }).then(transactionEventResponse => { this.sendCallResultWithMessage(message, transactionEventResponse) - .then(messageConfirmation => this._logger.debug("Transaction response sent:", messageConfirmation)); + .then(messageConfirmation => { + this._logger.debug("Transaction response sent: ", messageConfirmation) + }); }); } else { const response: TransactionEventResponse = { // TODO determine how to set chargingPriority and updatedPersonalMessage for anonymous users }; - this.sendCallResultWithMessage(message, response) - .then(messageConfirmation => this._logger.debug("Transaction response sent:", messageConfirmation)); + + const transaction: Transaction | undefined = await this._transactionEventRepository.readTransactionByStationIdAndTransactionId(stationId, transactionId); + + if (message.payload.eventType == TransactionEventEnumType.Updated) { + // I02 - Show EV Driver Running Total Cost During Charging + if (transaction && transaction.isActive && this._sendCostUpdatedOnMeterValue) { + response.totalCost = await this._calculateTotalCost(stationId, transaction.id); + } + + // I06 - Update Tariff Information During Transaction + const tariffAvailableAttributes: VariableAttribute[] = await this._deviceModelRepository.readAllByQuery({ + stationId: stationId, + component_name: 'TariffCostCtrlr', + variable_instance: 'Tariff', + variable_name: 'Available', + type: AttributeEnumType.Actual + }); + const supportTariff: boolean = tariffAvailableAttributes.length !== 0 && Boolean(tariffAvailableAttributes[0].value); + + if (supportTariff && transaction && transaction.isActive) { + this._logger.debug(`Checking if updated tariff information is available for traction ${transaction.transactionId}`); + // TODO: checks if there is updated tariff information available and set it in the PersonalMessage field. + } + } + + if (message.payload.eventType == TransactionEventEnumType.Ended && transaction) { + response.totalCost = await this._calculateTotalCost(stationId, transaction.id); + } + + this.sendCallResultWithMessage(message, response) + .then(messageConfirmation => { + this._logger.debug("Transaction response sent: ", messageConfirmation) + }); } } @@ -193,11 +296,16 @@ export class TransactionsModule extends AbstractModule { // TODO: Add meterValues to transactions // TODO: Meter values can be triggered. Ideally, it should be sent to the callbackUrl from the message api that sent the trigger message + // TODO: If sendCostUpdatedOnMeterValue is true, meterValues handler triggers cost update + // when it is added into a transaction const response: MeterValuesResponse = { // TODO determine how to set chargingPriority and updatedPersonalMessage for anonymous users }; - this.sendCallResultWithMessage(message, response) + + this.sendCallResultWithMessage(message, response).then(messageConfirmation => { + this._logger.debug("MeterValues response sent: ", messageConfirmation) + }) } @AsHandler(CallAction.StatusNotification) @@ -212,6 +320,116 @@ export class TransactionsModule extends AbstractModule { const response: StatusNotificationResponse = {}; this.sendCallResultWithMessage(message, response) - .then(messageConfirmation => this._logger.debug("StatusNotification response sent:", messageConfirmation)); + .then(messageConfirmation => { + this._logger.debug("StatusNotification response sent: ", messageConfirmation) + }); + } + + /** + * Handle responses + */ + + @AsHandler(CallAction.CostUpdated) + protected _handleCostUpdated( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("CostUpdated response received:", message, props); + } + + @AsHandler(CallAction.GetTransactionStatus) + protected _handleGetTransactionStatus( + message: IMessage, + props?: HandlerProperties + ): void { + this._logger.debug("GetTransactionStatus response received:", message, props); + } + + /** + * Round floor the given cost to 2 decimal places, e.g., given 1.2378, return 1.23 + * + * @param {number} cost - cost + * @return {number} rounded cost + */ + private _roundCost(cost: number): number { + return Math.floor(cost * 100) / 100 + } + + private async _calculateTotalCost(stationId: string, transactionDbId: number): Promise { + // TODO: This is a temp workaround. We need to refactor the calculation of totalCost when tariff + // implementation is finalized + let totalCost: number = 0; + + const tariff: Tariff | null = await this._tariffRepository.findByStationId(stationId); + if (tariff) { + this._logger.debug(`Tariff ${tariff.id} found for station ${stationId}`); + const totalKwh = this._getTotalKwh(await this._transactionEventRepository.readAllMeterValuesByTransactionDataBaseId(transactionDbId)); + this._logger.debug(`TotalKwh: ${totalKwh}`); + await Transaction.update({totalKwh: totalKwh}, {where: {id: transactionDbId}, returning: false}); + totalCost = this._roundCost(totalKwh * tariff.price); + } else { + this._logger.error(`Tariff not found for station ${stationId}`); + } + + return totalCost; + } + + /** + * Calculate the total Kwh + * + * @param {array} meterValues - meterValues of a transaction. + * @return {number} total Kwh based on the overall values (i.e., without phase) in the simpledValues. + */ + private _getTotalKwh(meterValues: MeterValue[]): number { + const contexts: ReadingContextEnumType[] = [ReadingContextEnumType.Transaction_Begin, ReadingContextEnumType.Sample_Periodic, ReadingContextEnumType.Transaction_End]; + + let valuesMap = new Map(); + + meterValues.filter(meterValue => meterValue.sampledValue[0].context && contexts.indexOf(meterValue.sampledValue[0].context) !== -1).forEach( + meterValue => { + const sampledValues = meterValue.sampledValue as SampledValueType[]; + const overallValue = sampledValues.find(sampledValue => sampledValue.phase === undefined && sampledValue.measurand == MeasurandEnumType.Energy_Active_Import_Register); + if (overallValue && overallValue.unitOfMeasure?.unit?.toUpperCase() === 'KWH') { + valuesMap.set(Date.parse(meterValue.timestamp), overallValue.value) + } else if (overallValue && overallValue.unitOfMeasure?.unit?.toUpperCase() === 'WH') { + valuesMap.set(Date.parse(meterValue.timestamp), overallValue.value / 1000) + } + } + ); + + // sort the map based on timestamps + valuesMap = new Map([...valuesMap.entries()].sort((v1, v2) => v1[0] - v2[0])); + const sortedValues = Array.from(valuesMap.values()); + + let totalKwh: number = 0; + for (let i = 1; i < sortedValues.length; i++) { + totalKwh += sortedValues[i] - sortedValues[i - 1]; + } + + return totalKwh; + } + + /** + * Internal method to execute a costUpdated request for an ongoing transaction repeatedly based on the costUpdatedInterval + * + * @param {string} stationId - The identifier of the client connection. + * @param {string} transactionId - The identifier of the transaction. + * @param {number} costUpdatedInterval - The costUpdated interval in milliseconds. + * @param {string} tenantId - The identifier of the tenant. + * @return {void} This function does not return anything. + */ + private _updateCost(stationId: string, transactionId: string, costUpdatedInterval: number, tenantId: string): void { + setInterval(async () => { + const transaction: Transaction | undefined = await this._transactionEventRepository.readTransactionByStationIdAndTransactionId(stationId, transactionId); + if (transaction && transaction.isActive) { + const cost = await this._calculateTotalCost(stationId, transaction.id); + this.sendCall(stationId, tenantId, CallAction.CostUpdated, { + totalCost: cost, + transactionId: transaction.transactionId + } as CostUpdatedRequest).then(() => { + this._logger.info(`Sent costUpdated for ${transaction.transactionId} with totalCost ${cost}`,); + }) + } + }, costUpdatedInterval * 1000); } } \ No newline at end of file diff --git a/03_Modules/Transactions/tsconfig.json b/03_Modules/Transactions/tsconfig.json index 5cebe347b..348e4d9e7 100644 --- a/03_Modules/Transactions/tsconfig.json +++ b/03_Modules/Transactions/tsconfig.json @@ -1,21 +1,23 @@ { - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": true, - "outDir": "lib", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, + "extends": "../../tsconfig.build.json", "include": [ - "src" + "src/**/*.ts", + "src/**/*.json" ], - "exclude": [ - "node_modules", - "**/__tests__/*" + "compilerOptions": { + "outDir": "./dist/", + "rootDir": "./src", + "composite": true + }, + "references": [ + { + "path": "../../00_Base" + }, + { + "path": "../../01_Data" + }, + { + "path": "../../02_Util" + } ] } \ No newline at end of file diff --git a/DirectusExtensions/charging-stations-bundle/package.json b/DirectusExtensions/charging-stations-bundle/package.json new file mode 100644 index 000000000..99eb6b400 --- /dev/null +++ b/DirectusExtensions/charging-stations-bundle/package.json @@ -0,0 +1,54 @@ +{ + "name": "directus-extension-charging-stations-bundle", + "description": "Directus extension bundle for CitrineOS to support charging stations", + "icon": "extension", + "version": "1.0.0", + "keywords": [ + "directus", + "directus-extension", + "directus-extension-bundle" + ], + "type": "module", + "files": [ + "dist" + ], + "directus:extension": { + "type": "bundle", + "path": { + "app": "dist/app.js", + "api": "dist/api.js" + }, + "entries": [ + { + "type": "display", + "name": "Present or True Count", + "source": "src/display-true-count/index.ts" + }, + { + "type": "endpoint", + "name": "Charging Stations endpoints", + "source": "src/endpoints-charging-stations/index.ts" + }, + { + "type": "hook", + "name": "On Create Charging Station", + "source": "src/hook-on-create-charging-station/index.ts" + } + ], + "host": "^10.10.0" + }, + "scripts": { + "build": "npx directus-extension build", + "dev": "npx directus-extension build -w --no-minify", + "link": "npx directus-extension link", + "add": "npx directus-extension add" + }, + "devDependencies": { + "@types/node": "^20.11.30", + "typescript": "^5.4.3" + }, + "dependencies": { + "@directus/extensions-sdk": "^11.0.1", + "@directus/types": "^11.0.7" + } +} diff --git a/DirectusExtensions/charging-stations-bundle/src/display-true-count/display.vue b/DirectusExtensions/charging-stations-bundle/src/display-true-count/display.vue new file mode 100644 index 000000000..54d6d83a9 --- /dev/null +++ b/DirectusExtensions/charging-stations-bundle/src/display-true-count/display.vue @@ -0,0 +1,55 @@ + + + \ No newline at end of file diff --git a/DirectusExtensions/charging-stations-bundle/src/display-true-count/index.ts b/DirectusExtensions/charging-stations-bundle/src/display-true-count/index.ts new file mode 100644 index 000000000..e8f15cf65 --- /dev/null +++ b/DirectusExtensions/charging-stations-bundle/src/display-true-count/index.ts @@ -0,0 +1,116 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { defineDisplay, useStores } from '@directus/extensions-sdk'; +import DisplayComponent from './display.vue'; +import { DeepPartial, Field, FieldMeta } from '@directus/types'; + +export default defineDisplay({ + id: 'directus-display-true-count', + name: 'Count Present or True', + icon: '123', + description: 'Count present or true rows in a column', + component: DisplayComponent, + options: ({ editing, relations }) => { + const relatedCollection = + relations.o2m?.meta?.junction_field != null ? relations.m2o?.related_collection : relations.o2m?.collection; + + const junction_table = relations.o2m?.meta?.junction_field != null ? relations.o2m?.collection : null; + const { useFieldsStore } = useStores(); + const fieldsStore = useFieldsStore(); + + let fieldSelection: DeepPartial; + if (editing === '+') { + fieldSelection = { + interface: 'presentation-notice', + options: { + text: 'Please complete the field before attempting to configure the display.', + }, + width: 'full', + }; + } else { + const fields: Field[] = fieldsStore.getFieldsForCollection(relatedCollection); + const field_choices: object[] = []; + + // console.log("fields", fields); + + fields.forEach((field) => { + // console.log(field); + field_choices.push({ + text: field.field, + value: junction_table ? `${relations.o2m?.meta?.junction_field}.${field.field}` : field.field, + }); + }); + + fieldSelection = { + interface: 'select-dropdown', + options: { + choices: field_choices, + }, + width: 'full', + }; + } + + return [ + { + field: 'column', + name: 'Choose a column', + meta: fieldSelection, + }, + { + field: 'showTotal', + type: 'boolean', + name: 'Show Total', + meta: { + interface: 'boolean', + options: { + label: 'Show Total', + }, + width: 'half', + }, + }, + { + field: 'totalPrefix', + type: 'string', + name: 'Total Prefix', + meta: { + interface: 'input', + options: { + font: 'monospace', + }, + width: 'half', + hidden: true, + conditions: [ + { + name: 'showTotalTrue', + rule: { + "showTotal": { + "_eq": true + } + }, + hidden: false + } + ] + }, + }, + { + field: 'suffix', + type: 'string', + name: 'Suffix', + meta: { + interface: 'input', + options: { + font: 'monospace', + }, + width: 'half', + }, + }, + ]; + }, + types: ['alias', 'string', 'uuid', 'integer', 'bigInteger', 'json'], + localTypes: ['m2m', 'm2o', 'o2m', 'translations', 'm2a', 'file', 'files'], + fields: (options) => { + return [options.column]; + }, +}); \ No newline at end of file diff --git a/DirectusExtensions/charging-stations-bundle/src/display-true-count/shims.d.ts b/DirectusExtensions/charging-stations-bundle/src/display-true-count/shims.d.ts new file mode 100644 index 000000000..9923fb605 --- /dev/null +++ b/DirectusExtensions/charging-stations-bundle/src/display-true-count/shims.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import { DefineComponent } from 'vue'; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/DirectusExtensions/charging-stations-bundle/src/endpoints-charging-stations/index.ts b/DirectusExtensions/charging-stations-bundle/src/endpoints-charging-stations/index.ts new file mode 100644 index 000000000..7e20bbb43 --- /dev/null +++ b/DirectusExtensions/charging-stations-bundle/src/endpoints-charging-stations/index.ts @@ -0,0 +1,38 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { defineEndpoint } from '@directus/extensions-sdk'; + +export default defineEndpoint({ + id: 'charging-stations', + handler: (router, { database }) => { + router.post('/update-station-status', async (req, res) => { + try { + console.log("update-station-status request received: " + JSON.stringify(req.body)); + const { stationId, event } = req.body; + let isOnline = false; + + // Determine the status based on the event type + if (event === 'connected') { + isOnline = true; + } else if (event === 'closed') { + isOnline = false; + } else { + // If the event type is neither 'connected' nor 'closed', return an error + return res.status(400).json({ message: 'Invalid event type, expecting only "connected" or "closed"' }); + } + + // Update the `isOnline` field in the `ChargingStation` collection for the specified stationId + await database('ChargingStations') + .where({ id: stationId }) + .update({ isOnline }); + + return res.status(200).json({ message: 'ChargingStation status updated successfully' }); + } catch (error) { + console.error('Error updating ChargingStation status:', error); + return res.status(500).json({ message: 'Internal server error' }); + } + }); + } +}); \ No newline at end of file diff --git a/DirectusExtensions/charging-stations-bundle/src/hook-on-create-charging-station/index.ts b/DirectusExtensions/charging-stations-bundle/src/hook-on-create-charging-station/index.ts new file mode 100644 index 000000000..27183812f --- /dev/null +++ b/DirectusExtensions/charging-stations-bundle/src/hook-on-create-charging-station/index.ts @@ -0,0 +1,35 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { defineHook } from '@directus/extensions-sdk'; + +export default defineHook(({ action }, { env }) => { + + action('ChargingStations.items.create', (input) => { + console.log("Subscribing " + input.key + " to connect and close events"); + + const stationId = input.key; + const subscriptionUrl = `${env.CITRINEOS_URL}${env.CITRINEOS_SUBSCRIPTION_API_PATH}`; + const updateStationStatusUrl = `${env.DIRECTUS_URL}${env.DIRECTUS_CHARGING_STATION_UPDATE_STATUS_PATH}`; + const requestBody = { + stationId: stationId, + onConnect: true, + onClose: true, + url: updateStationStatusUrl + } + + console.log("Subscribing to " + subscriptionUrl + " with request body " + JSON.stringify(requestBody)); + fetch(subscriptionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }).then((response) => { + console.log('Response: ', response); + }).catch((error) => { + console.log('Error: ', error); + }); + }); +}); \ No newline at end of file diff --git a/DirectusExtensions/charging-stations-bundle/tsconfig.json b/DirectusExtensions/charging-stations-bundle/tsconfig.json new file mode 100644 index 000000000..4893f439f --- /dev/null +++ b/DirectusExtensions/charging-stations-bundle/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.build.json", + "include": [ + "src/**/*.ts", + "src/**/*.json" + ], + "compilerOptions": { + "outDir": "./dist/", + "composite": true, + "rootDir": "./src" + } +} \ No newline at end of file diff --git a/README.md b/README.md index 5f762c640..db1038831 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,30 @@ # Welcome to CitrineOS -CitrineOS is an open-source project aimed at providing a modular server runtime for managing Electric Vehicle (EV) charging infrastructure. This README will guide you through the process of installing and running CitrineOS. +CitrineOS is an open-source project aimed at providing a modular server runtime for managing Electric Vehicle (EV) +charging infrastructure. This README will guide you through the process of installing and running CitrineOS. -This is the main part of CitrineOS containing the actual charging station management logic, OCPP message routing and all modules. +This is the main part of CitrineOS containing the actual charging station management logic, OCPP message routing and all +modules. -All other documentation and the issue tracking can be found in our main repository here: https://github.com/citrineos/citrineos. +All other documentation and the issue tracking can be found in our main repository +here: https://github.com/citrineos/citrineos. ## Overview -CitrineOS is developed in TypeScript and runs on `NodeJS` with [ws](https://github.com/websockets/ws) and [fastify](https://fastify.dev/). +CitrineOS is developed in TypeScript and runs on `NodeJS` with [ws](https://github.com/websockets/ws) +and [fastify](https://fastify.dev/). The system features: - Dynamic OCPP 2.0.1 message schema validation, prior to transmission using `AJV` - Generated OpenAPIv3 specification for easy developer access - Configurable logical modules with decorators - - `@AsHandler` to handle incoming OCPP 2.0.1 messages - - `@AsMessageEndpoint` to expose functions allowing to send messages to charging stations - - `@AsDataEndpoint` to expose CRUD access to entities defined in `10_Data` + - `@AsHandler` to handle incoming OCPP 2.0.1 messages + - `@AsMessageEndpoint` to expose functions allowing to send messages to charging stations + - `@AsDataEndpoint` to expose CRUD access to entities defined in `10_Data` - Utilities to connect and extend various message broker and cache mechanisms - - Currently supported brokers are `RabbitMQ` and Google Cloud `PubSub` - - Currently supported caches are `In Memory` and `Redis` + - Currently supported brokers are `RabbitMQ` and Google Cloud `PubSub` + - Currently supported caches are `In Memory` and `Redis` For more information on the project go to [citrineos.github.io](https://citrineos.github.io). @@ -33,6 +37,7 @@ Before you begin, make sure you have the following installed on your system: - Node.js (v18 or higher): [Download Node.js](https://nodejs.org/) - npm (Node Package Manager): [Download npm](https://www.npmjs.com/get-npm) - Docker (Optional). Version >= 20.10: [Download Docker](https://docs.docker.com/get-docker/) + ### Installation 1. Clone the CitrineOS repository to your local machine: @@ -41,45 +46,74 @@ Before you begin, make sure you have the following installed on your system: git clone https://github.com/citrineos/citrineos-core ``` -1. Navigate to the CitrineOS Server directory: +1. Install project dependencies from root dir: - ```shell - cd citrineos-core/Server - ``` + ```shell + npm run install-all + ``` -1. Install project dependencies: +1. Build project from root dir: ```shell - ./unix-init-install-all.sh + npm run build ``` -1. Start the server and its supporting infrastructure with: +1. The docker container should be initialized from `cd /Server` by running `docker-compose -f ./docker-compose.yml up -d` or + by using the IntelliJ `Server` Run Configuration which was created for this purpose. - ```shell - docker-compose up -d - ``` +1. Running `docker-compose.yml` will ensure that the container is configured to expose the `:9229` debugging + port for the underlying NodeJS process. A variety of tools can be utilized to establish a debugger connection + with the exposed localhost 9229 port which is forwarded to the NodeJS service running within docker. The IntelliJ + `Attach Debugger` Run Configuration was made to attach to a debugging session. ### Starting the Server without Docker CitrineOS requires configuration to allow your OCPP 2.0.1 compliant charging stations to connect. -We recommend running and developing the project with the `docker-compose` set-up. +We recommend running and developing the project with the `docker-compose` set-up via the existing Run Configurations. +Additional Run Configurations should be made for other IDEs (ex VSCode). -To change necessary configuration for execution outside of `docker-compose`, please adjust the configuration file at `50_Server/src/config/envs/local.ts`. Make sure any changes to the local configuration do not make it into your PR. +To change necessary configuration for execution outside of `docker-compose`, please adjust the configuration file +at `50_Server/src/config/envs/local.ts`. Make sure any changes to the local configuration do not make it into your PR. ### Starting the Server To start the CitrineOS server, run the following command: ```shell -npm run start-unix:local +cd Server +npm run start:local ``` -This will launch the CitrineOS server with the specified configuration. +This will launch the CitrineOS server with the specified configuration. The debugger will be available +on port 9229. + +### Attaching Debugger + +Whether you run the application with Docker or locally with npm, you should be able to attach a debugger. +With debugger attached you should be able to set breakpoints in the TS code right from your IDE and debug +with ease. + +### Attaching Debugger before execution using `--inspect-brk` + +You can modify `nodemon.json` exec command from: + +```shell +node --inspect=0.0.0.0:9229 --nolazy -r ts-node/register +``` + +to + +```shell +node --inspect-brk=0.0.0.0:9229 --nolazy -r ts-node/register +``` + +which will wait for the debugger to attach before proceeding with execution. ### Usage -You can now connect your OCPP 2.0.1 compliant charging stations to the CitrineOS server. Make sure to configure the charging stations to point to the server's IP address and port as specified in the config.json file. +You can now connect your OCPP 2.0.1 compliant charging stations to the CitrineOS server. Make sure to configure the +charging stations to point to the server's IP address and port as specified in the config.json file. ## Information on Docker setup @@ -92,19 +126,19 @@ a common integrated development environment. Once Docker is running, the following services should be available: -- **CitrineOS** (service name: citrineos) with ports - - `8080`: webserver http - [Swagger](http://localhost:8080/docs) - - `8081`: websocket server tcp connection without auth - - `8082`: websocket server tcp connection with basic http auth -- **RabbitMQ Broker** (service name: amqp-broker) with ports - - `5672`: amqp tcp connection - - `15672`: RabbitMQ [management interface](http://localhost:15672) -- **PostgreSQL** (service name: ocpp-db), PostgreSQL database for persistence - - `5432`: sql tcp connection -- **Directus** (service name: directus) on port 8055 with endpoints - - `:8055/admin`: web interface (login = admin@citrineos.com:CitrineOS!) - -These three services are defined in `Server/docker-compose.yml` and they +- **CitrineOS** (service name: citrineos) with ports + - `8080`: webserver http - [Swagger](http://localhost:8080/docs) + - `8081`: websocket server tcp connection without auth + - `8082`: websocket server tcp connection with basic http auth +- **RabbitMQ Broker** (service name: amqp-broker) with ports + - `5672`: amqp tcp connection + - `15672`: RabbitMQ [management interface](http://localhost:15672) +- **PostgreSQL** (service name: ocpp-db), PostgreSQL database for persistence + - `5432`: sql tcp connection +- **Directus** (service name: directus) on port 8055 with endpoints + - `:8055/admin`: web interface (login = admin@citrineos.com:CitrineOS!) + +These three services are defined in `docker-compose.yml` and they live inside the docker network `docker_default` with their respective ports. By default these ports are directly accessible by using `localhost:8080` for example. @@ -114,16 +148,20 @@ localhost, you need to access `localhost:15672`. ## Generating OCPP Interfaces -All CitrineOS interfaces for OCPP 2.0.1-defined schemas were procedurally generated using the script in 00_Base/json-schema-processor.js. +All CitrineOS interfaces for OCPP 2.0.1-defined schemas were procedurally generated using the script in +00_Base/json-schema-processor.js. It can be rerun: + ```shell npm run generate-interfaces -- ../../Path/To/OCPP-2.0.1_part3_JSON_schemas ``` + This will replace all the files in `00_Base/src/ocpp/model/`, ## Contributing -We welcome contributions from the community. If you would like to contribute to CitrineOS, please follow our [contribution guidelines](https://github.com/citrineos/citrineos/blob/main/CONTRIBUTING.md). +We welcome contributions from the community. If you would like to contribute to CitrineOS, please follow +our [contribution guidelines](https://github.com/citrineos/citrineos/blob/main/CONTRIBUTING.md). ## Licensing @@ -131,7 +169,8 @@ CitrineOS and its subprojects are licensed under the Apache License, Version 2.0 ## Support and Contact -If you have any questions or need assistance, feel free to reach out to us on our community forum or create an issue on the GitHub repository. +If you have any questions or need assistance, feel free to reach out to us on our community forum or create an issue on +the GitHub repository. ## Roadmap diff --git a/Server/.dockerignore b/Server/.dockerignore new file mode 100644 index 000000000..9905ce70e --- /dev/null +++ b/Server/.dockerignore @@ -0,0 +1,19 @@ +../node_modules +dist +*.log +*.lock +.dockerignore +Server/deploy.Dockerfile +Server/docker-compose.yml + +Server/dist +00_Base/dist +01_Data/dist +02_Util/dist +03_Modules/Certificates/dist +03_Modules/Configuration/dist +03_Modules/EVDriver/dist +03_Modules/Monitoring/dist +03_Modules/Reporting/dist +03_Modules/SmartCharging/dist +03_Modules/Transactions/dist \ No newline at end of file diff --git a/Server/data/directus/uploads/2937fa22-293e-4aae-805e-ad83c7843a6e.png b/Server/data/directus/uploads/2937fa22-293e-4aae-805e-ad83c7843a6e.png deleted file mode 100644 index a8088da87..000000000 Binary files a/Server/data/directus/uploads/2937fa22-293e-4aae-805e-ad83c7843a6e.png and /dev/null differ diff --git a/Server/data/directus/uploads/2937fa22-293e-4aae-805e-ad83c7843a6e__589fd140dc03b11360453048b8c68765d61556b2.png b/Server/data/directus/uploads/2937fa22-293e-4aae-805e-ad83c7843a6e__589fd140dc03b11360453048b8c68765d61556b2.png deleted file mode 100644 index 4e4a7bb70..000000000 Binary files a/Server/data/directus/uploads/2937fa22-293e-4aae-805e-ad83c7843a6e__589fd140dc03b11360453048b8c68765d61556b2.png and /dev/null differ diff --git a/Server/data/directus/uploads/2d6b4619-1cd0-48f6-99ea-bdc292688342.png b/Server/data/directus/uploads/2d6b4619-1cd0-48f6-99ea-bdc292688342.png deleted file mode 100644 index a8088da87..000000000 Binary files a/Server/data/directus/uploads/2d6b4619-1cd0-48f6-99ea-bdc292688342.png and /dev/null differ diff --git a/Server/data/directus/uploads/2d6b4619-1cd0-48f6-99ea-bdc292688342__4f7f2791732f0b8f33c44abe69d6e5b615ea3f17.png b/Server/data/directus/uploads/2d6b4619-1cd0-48f6-99ea-bdc292688342__4f7f2791732f0b8f33c44abe69d6e5b615ea3f17.png deleted file mode 100644 index c6d4a372f..000000000 Binary files a/Server/data/directus/uploads/2d6b4619-1cd0-48f6-99ea-bdc292688342__4f7f2791732f0b8f33c44abe69d6e5b615ea3f17.png and /dev/null differ diff --git a/Server/data/directus/uploads/2d6b4619-1cd0-48f6-99ea-bdc292688342__589fd140dc03b11360453048b8c68765d61556b2.png b/Server/data/directus/uploads/2d6b4619-1cd0-48f6-99ea-bdc292688342__589fd140dc03b11360453048b8c68765d61556b2.png deleted file mode 100644 index 307ebb830..000000000 Binary files a/Server/data/directus/uploads/2d6b4619-1cd0-48f6-99ea-bdc292688342__589fd140dc03b11360453048b8c68765d61556b2.png and /dev/null differ diff --git a/Server/data/directus/uploads/9fbe60db-0d6b-452f-9100-3c148e025fd1.png b/Server/data/directus/uploads/9fbe60db-0d6b-452f-9100-3c148e025fd1.png deleted file mode 100644 index a8088da87..000000000 Binary files a/Server/data/directus/uploads/9fbe60db-0d6b-452f-9100-3c148e025fd1.png and /dev/null differ diff --git a/Server/data/directus/uploads/9fbe60db-0d6b-452f-9100-3c148e025fd1__589fd140dc03b11360453048b8c68765d61556b2.png b/Server/data/directus/uploads/9fbe60db-0d6b-452f-9100-3c148e025fd1__589fd140dc03b11360453048b8c68765d61556b2.png deleted file mode 100644 index 4e4a7bb70..000000000 Binary files a/Server/data/directus/uploads/9fbe60db-0d6b-452f-9100-3c148e025fd1__589fd140dc03b11360453048b8c68765d61556b2.png and /dev/null differ diff --git a/Server/data/directus/uploads/c54bf973-4c89-4298-8e87-6c69fbd4fd96.png b/Server/data/directus/uploads/c54bf973-4c89-4298-8e87-6c69fbd4fd96.png deleted file mode 100644 index a8088da87..000000000 Binary files a/Server/data/directus/uploads/c54bf973-4c89-4298-8e87-6c69fbd4fd96.png and /dev/null differ diff --git a/Server/data/directus/uploads/c54bf973-4c89-4298-8e87-6c69fbd4fd96__589fd140dc03b11360453048b8c68765d61556b2.png b/Server/data/directus/uploads/c54bf973-4c89-4298-8e87-6c69fbd4fd96__589fd140dc03b11360453048b8c68765d61556b2.png deleted file mode 100644 index 4e4a7bb70..000000000 Binary files a/Server/data/directus/uploads/c54bf973-4c89-4298-8e87-6c69fbd4fd96__589fd140dc03b11360453048b8c68765d61556b2.png and /dev/null differ diff --git a/Server/data/directus/uploads/e79f2dd1-73b3-42eb-9c85-e0aae4ee1d68.png b/Server/data/directus/uploads/e79f2dd1-73b3-42eb-9c85-e0aae4ee1d68.png deleted file mode 100644 index a8088da87..000000000 Binary files a/Server/data/directus/uploads/e79f2dd1-73b3-42eb-9c85-e0aae4ee1d68.png and /dev/null differ diff --git a/Server/data/directus/uploads/e79f2dd1-73b3-42eb-9c85-e0aae4ee1d68__589fd140dc03b11360453048b8c68765d61556b2.png b/Server/data/directus/uploads/e79f2dd1-73b3-42eb-9c85-e0aae4ee1d68__589fd140dc03b11360453048b8c68765d61556b2.png deleted file mode 100644 index 4e4a7bb70..000000000 Binary files a/Server/data/directus/uploads/e79f2dd1-73b3-42eb-9c85-e0aae4ee1d68__589fd140dc03b11360453048b8c68765d61556b2.png and /dev/null differ diff --git a/Server/data/directus/uploads/fe3776b9-c57e-4347-9b36-43c3f37c39a0.png b/Server/data/directus/uploads/fe3776b9-c57e-4347-9b36-43c3f37c39a0.png deleted file mode 100644 index bd42d3a82..000000000 Binary files a/Server/data/directus/uploads/fe3776b9-c57e-4347-9b36-43c3f37c39a0.png and /dev/null differ diff --git a/Server/data/directus/uploads/fe3776b9-c57e-4347-9b36-43c3f37c39a0__589fd140dc03b11360453048b8c68765d61556b2.png b/Server/data/directus/uploads/fe3776b9-c57e-4347-9b36-43c3f37c39a0__589fd140dc03b11360453048b8c68765d61556b2.png deleted file mode 100644 index 57c6841fb..000000000 Binary files a/Server/data/directus/uploads/fe3776b9-c57e-4347-9b36-43c3f37c39a0__589fd140dc03b11360453048b8c68765d61556b2.png and /dev/null differ diff --git a/Server/deploy.Dockerfile b/Server/deploy.Dockerfile new file mode 100644 index 000000000..b26c3c25c --- /dev/null +++ b/Server/deploy.Dockerfile @@ -0,0 +1,15 @@ +FROM node:18 + +WORKDIR /usr/local/apps/citrineos + +COPY ../ . + +RUN npm i && npm run build + +# TODO remove src files + +EXPOSE ${PORT} + +WORKDIR /usr/local/apps/citrineos/Server + +CMD ["npm", "run", "start"] diff --git a/Server/directus-env-config.cjs b/Server/directus-env-config.cjs new file mode 100644 index 000000000..c9cbef871 --- /dev/null +++ b/Server/directus-env-config.cjs @@ -0,0 +1,16 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +module.exports = function (env) { + const config = { + // API Paths + CITRINEOS_SUBSCRIPTION_API_PATH: '/data/ocpprouter/subscription', + DIRECTUS_CHARGING_STATION_UPDATE_STATUS_PATH: '/charging-stations/update-station-status', + // Environment-specific urls + CITRINEOS_URL: "http://citrine:8080", + DIRECTUS_URL: "http://directus:8055" + } + + return config; +} diff --git a/Server/directus.Dockerfile b/Server/directus.Dockerfile new file mode 100644 index 000000000..3e6ba9e4d --- /dev/null +++ b/Server/directus.Dockerfile @@ -0,0 +1,8 @@ +FROM directus/directus:10.10.5 +USER root +COPY tsconfig.build.json /directus +COPY DirectusExtensions/charging-stations-bundle/tsconfig.json /directus/extensions/directus-extension-charging-stations-bundle/tsconfig.json +COPY DirectusExtensions/charging-stations-bundle/package.json /directus/extensions/directus-extension-charging-stations-bundle/package.json +COPY DirectusExtensions/charging-stations-bundle/src /directus/extensions/directus-extension-charging-stations-bundle/src +RUN npm install --prefix /directus/extensions/directus-extension-charging-stations-bundle && npm run build --prefix /directus/extensions/directus-extension-charging-stations-bundle +USER node \ No newline at end of file diff --git a/Server/docker-compose.swarm.yml b/Server/docker-compose.swarm.yml new file mode 100644 index 000000000..66ee50c52 --- /dev/null +++ b/Server/docker-compose.swarm.yml @@ -0,0 +1,229 @@ +version: '3' +services: + amqp-broker: + image: rabbitmq:3-management + ports: + - 15672:15672 + - 5672:5672 + environment: + RABBITMQ_DEFAULT_USER: 'guest' + RABBITMQ_DEFAULT_PASS: 'guest' + volumes: + - ./data/rabbitmq:/var/lib/rabbitmq + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 10s + timeout: 10s + retries: 3 + ocpp-db: + image: citrineos/postgres:preseeded + ports: + - 5432:5432 + volumes: + - ./data/postgresql/pgdata:/var/lib/postgresql/data + environment: + POSTGRES_DB: citrine + POSTGRES_USER: citrine + POSTGRES_PASSWORD: "citrine" + redis: + build: + context: .. + dockerfile: .redis.Dockerfile + ports: + - "6379:6379" + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 10s + timeout: 5s + retries: 3 + directus: + image: directus/directus:latest + ports: + - 8055:8055 + volumes: + - ./data/directus/uploads:/directus/uploads + - ./data/directus/extensions:/directus/extensions + environment: + KEY: '1234567890' + SECRET: '0987654321' + ADMIN_EMAIL: 'admin@citrineos.com' + ADMIN_PASSWORD: 'CitrineOS!' + DB_CLIENT: 'pg' + DB_HOST: ocpp-db + DB_PORT: 5432 + DB_DATABASE: 'citrine' + DB_USER: 'citrine' + DB_PASSWORD: 'citrine' + WEBSOCKETS_ENABLED: 'true' + citrine: + build: + context: .. + dockerfile: ./Server/deploy.Dockerfile + depends_on: + ocpp-db: + condition: service_started + amqp-broker: + condition: service_healthy + redis: + condition: service_healthy + ports: + - 8080:8080 + - 8081:8081 + - 8082:8082 + expose: + - 8080-8082 + environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' + APP_NAME: 'general' + certificates: + build: + context: .. + dockerfile: ./Server/deploy.Dockerfile + depends_on: + ocpp-db: + condition: service_started + amqp-broker: + condition: service_healthy + redis: + condition: service_healthy + ports: + - 8083:8083 + expose: + - 8083 + environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' + APP_NAME: 'certificates' + configuration: + build: + context: ../.. + dockerfile: .deploy.Dockerfile + depends_on: + ocpp-db: + condition: service_started + amqp-broker: + condition: service_healthy + redis: + condition: service_healthy + ports: + - 8084:8084 + expose: + - 8084 + volumes: + - ./:/usr/configuration + - /usr/configuration/node_modules + environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' + APP_NAME: 'configuration' + evdriver: + build: + context: ../.. + dockerfile: .deploy.Dockerfile + depends_on: + ocpp-db: + condition: service_started + amqp-broker: + condition: service_healthy + redis: + condition: service_healthy + ports: + - 8085:8085 + expose: + - 8085 + volumes: + - ./:/usr/evdriver + - /usr/evdriver/node_modules + environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' + APP_NAME: 'evdriver' + monitoring: + build: + context: ../.. + dockerfile: .deploy.Dockerfile + depends_on: + ocpp-db: + condition: service_started + amqp-broker: + condition: service_healthy + redis: + condition: service_healthy + ports: + - 8086:8086 + expose: + - 8086 + volumes: + - ./:/usr/monitoring + - /usr/monitoring/node_modules + environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' + APP_NAME: 'monitoring' + reporting: + build: + context: ../.. + dockerfile: .deploy.Dockerfile + depends_on: + ocpp-db: + condition: service_started + amqp-broker: + condition: service_healthy + redis: + condition: service_healthy + ports: + - 8087:8087 + expose: + - 8087 + volumes: + - ./:/usr/reporting + - /usr/reporting/node_modules + environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' + APP_NAME: 'reporting' + smartcharging: + build: + context: ../.. + dockerfile: .deploy.Dockerfile + depends_on: + ocpp-db: + condition: service_started + amqp-broker: + condition: service_healthy + redis: + condition: service_healthy + ports: + - 8088:8088 + expose: + - 8088 + volumes: + - ./:/usr/smartcharging + - /usr/smartcharging/node_modules + environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' + APP_NAME: 'smartcharging' + transactions: + build: + context: ../.. + dockerfile: .deploy.Dockerfile + depends_on: + ocpp-db: + condition: service_started + amqp-broker: + condition: service_healthy + redis: + condition: service_healthy + ports: + - 8089:8089 + expose: + - 8089 + volumes: + - ./:/usr/transactions + - /usr/transactions/node_modules + environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' + APP_NAME: 'transactions' diff --git a/Server/docker-compose.yml b/Server/docker-compose.yml index a9a636923..cc9febe85 100644 --- a/Server/docker-compose.yml +++ b/Server/docker-compose.yml @@ -16,7 +16,7 @@ services: timeout: 10s retries: 3 ocpp-db: - image: citrineos/postgres:preseeded + image: citrineos/postgis:v1.1.0 ports: - 5432:5432 volumes: @@ -25,42 +25,94 @@ services: POSTGRES_DB: citrine POSTGRES_USER: citrine POSTGRES_PASSWORD: "citrine" + healthcheck: + test: [ "CMD-SHELL", "pg_isready", "-d", "db_prod" ] + interval: 30s + timeout: 60s + retries: 5 + start_period: 80s redis: image: redis:latest ports: - "6379:6379" healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: [ "CMD", "redis-cli", "ping" ] interval: 10s timeout: 5s retries: 3 citrine: build: - context: ../ - dockerfile: ./Server/docker/Dockerfile + context: .. + dockerfile: ./Server/local.Dockerfile + volumes: + - ../:/usr/local/apps/citrineos + - /usr/local/apps/citrineos/node_modules + - /usr/local/apps/citrineos/Server/node_modules + - /usr/local/apps/citrineos/00_Base/node_modules + - /usr/local/apps/citrineos/01_Data/node_modules + - /usr/local/apps/citrineos/02_Util/node_modules + - /usr/local/apps/citrineos/03_Modules/Certificates/node_modules + - /usr/local/apps/citrineos/03_Modules/Configuration/node_modules + - /usr/local/apps/citrineos/03_Modules/EVDriver/node_modules + - /usr/local/apps/citrineos/03_Modules/Monitoring/node_modules + - /usr/local/apps/citrineos/03_Modules/OcppRouter/node_modules + - /usr/local/apps/citrineos/03_Modules/Reporting/node_modules + - /usr/local/apps/citrineos/03_Modules/SmartCharging/node_modules + - /usr/local/apps/citrineos/03_Modules/Transactions/node_modules + - /usr/local/apps/citrineos/dist/ + - /usr/local/apps/citrineos/Server/dist/ + - /usr/local/apps/citrineos/00_Base/dist/ + - /usr/local/apps/citrineos/01_Data/dist/ + - /usr/local/apps/citrineos/02_Util/dist/ + - /usr/local/apps/citrineos/03_Modules/Certificates/dist/ + - /usr/local/apps/citrineos/03_Modules/Configuration/dist/ + - /usr/local/apps/citrineos/03_Modules/EVDriver/dist/ + - /usr/local/apps/citrineos/03_Modules/Monitoring/dist/ + - /usr/local/apps/citrineos/03_Modules/OcppRouter/dist/ + - /usr/local/apps/citrineos/03_Modules/Reporting/dist/ + - /usr/local/apps/citrineos/03_Modules/SmartCharging/dist/ + - /usr/local/apps/citrineos/03_Modules/Transactions/dist/ + environment: + APP_NAME: "all" + APP_ENV: "docker" + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' depends_on: ocpp-db: condition: service_started amqp-broker: condition: service_healthy + directus: + condition: service_healthy redis: condition: service_healthy ports: - 8080:8080 - 8081:8081 - 8082:8082 + - 9229:9229 + directus: - image: directus/directus:latest + build: + context: .. + dockerfile: ./Server/directus.Dockerfile ports: - 8055:8055 volumes: - ./data/directus/uploads:/directus/uploads - - ./data/directus/extensions:/directus/extensions + - ./directus-env-config.cjs:/directus/config.cjs + depends_on: + ocpp-db: + condition: service_healthy environment: + APP_NAME: 'all' KEY: '1234567890' SECRET: '0987654321' ADMIN_EMAIL: 'admin@citrineos.com' ADMIN_PASSWORD: 'CitrineOS!' + CONFIG_PATH: '/directus/config.cjs' + EXTENSIONS_AUTO_RELOAD: 'true' + EXTENSIONS_CACHE_TTL: '1s' DB_CLIENT: 'pg' DB_HOST: ocpp-db DB_PORT: 5432 @@ -68,3 +120,9 @@ services: DB_USER: 'citrine' DB_PASSWORD: 'citrine' WEBSOCKETS_ENABLED: 'true' + healthcheck: + test: wget --no-verbose --tries=1 --spider http://localhost:8055/server/health || exit 1 + start_period: 15s + interval: 15s + timeout: 15s + retries: 3 \ No newline at end of file diff --git a/Server/docker/.dockerignore b/Server/docker/.dockerignore deleted file mode 100644 index 198a2e52a..000000000 --- a/Server/docker/.dockerignore +++ /dev/null @@ -1,10 +0,0 @@ -node_modules -lib -data -.eslintrc.json -.gitignore -*.tgz -*.sh -*.js -*.md -*.log \ No newline at end of file diff --git a/Server/docker/Dockerfile b/Server/docker/Dockerfile deleted file mode 100644 index 2ce428d51..000000000 --- a/Server/docker/Dockerfile +++ /dev/null @@ -1,155 +0,0 @@ -FROM node:18 as base - -# To work from package-lock.json for consistency, replace 'package' with 'package*' and 'install' with 'ci' -# This will not work for package-lock files generated on Windows machines. - -# Build citrineos-base module -FROM base as citrineos-base-builder -COPY /00_Base/package.json /usr/00_Base/ -RUN npm install --ignore-scripts=true --prefix /usr/00_Base - -COPY /00_Base/tsconfig.json /usr/00_Base/ -COPY /00_Base/src /usr/00_Base/src -RUN npm run build --prefix /usr/00_Base -RUN cd /usr/00_Base && npm pack - -# Build citrineos-data module -FROM base as citrineos-data-builder -COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ -COPY /01_Data/package.json /usr/01_Data/ -RUN npm install --ignore-scripts=true --prefix /usr/01_Data - -COPY /01_Data/tsconfig.json /usr/01_Data/ -COPY /01_Data/src /usr/01_Data/src -RUN npm run build --prefix /usr/01_Data -RUN cd /usr/01_Data && npm pack - -# Build citrineos-util module -FROM base as citrineos-util-builder -COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ -COPY /02_Util/package.json /usr/02_Util/ -RUN npm install --ignore-scripts=true --prefix /usr/02_Util - -COPY /02_Util/tsconfig.json /usr/02_Util/ -COPY /02_Util/src /usr/02_Util/src -RUN npm run build --prefix /usr/02_Util -RUN cd /usr/02_Util && npm pack - -# Build citrineos-certificates module -FROM base as citrineos-certificates-builder -COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ -COPY --from=citrineos-data-builder /usr/01_Data/*.tgz /usr/01_Data/ -COPY --from=citrineos-util-builder /usr/02_Util/*.tgz /usr/02_Util/ -COPY /03_Modules/Certificates/package.json /usr/03_Modules/Certificates/ -RUN npm install --ignore-scripts=true --prefix /usr/03_Modules/Certificates - -COPY /03_Modules/Certificates/tsconfig.json /usr/03_Modules/Certificates/ -COPY /03_Modules/Certificates/src /usr/03_Modules/Certificates/src -RUN npm run build --prefix /usr/03_Modules/Certificates -RUN cd /usr/03_Modules/Certificates && npm pack - -# Build citrineos-configuration module -FROM base as citrineos-configuration-builder -COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ -COPY --from=citrineos-data-builder /usr/01_Data/*.tgz /usr/01_Data/ -COPY --from=citrineos-util-builder /usr/02_Util/*.tgz /usr/02_Util/ -COPY /03_Modules/Configuration/package.json /usr/03_Modules/Configuration/ -RUN npm install --ignore-scripts=true --prefix /usr/03_Modules/Configuration - -COPY /03_Modules/Configuration/tsconfig.json /usr/03_Modules/Configuration/ -COPY /03_Modules/Configuration/src /usr/03_Modules/Configuration/src -RUN npm run build --prefix /usr/03_Modules/Configuration -RUN cd /usr/03_Modules/Configuration && npm pack - -# Build citrineos-evdriver module -FROM base as citrineos-evdriver-builder -COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ -COPY --from=citrineos-data-builder /usr/01_Data/*.tgz /usr/01_Data/ -COPY --from=citrineos-util-builder /usr/02_Util/*.tgz /usr/02_Util/ -COPY /03_Modules/EVDriver/package.json /usr/03_Modules/EVDriver/ -RUN npm install --ignore-scripts=true --prefix /usr/03_Modules/EVDriver - -COPY /03_Modules/EVDriver/tsconfig.json /usr/03_Modules/EVDriver/ -COPY /03_Modules/EVDriver/src /usr/03_Modules/EVDriver/src -RUN npm run build --prefix /usr/03_Modules/EVDriver -RUN cd /usr/03_Modules/EVDriver && npm pack - -# Build citrineos-monitoring module -FROM base as citrineos-monitoring-builder -COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ -COPY --from=citrineos-data-builder /usr/01_Data/*.tgz /usr/01_Data/ -COPY --from=citrineos-util-builder /usr/02_Util/*.tgz /usr/02_Util/ -COPY /03_Modules/Monitoring/package.json /usr/03_Modules/Monitoring/ -RUN npm install --ignore-scripts=true --prefix /usr/03_Modules/Monitoring - -COPY /03_Modules/Monitoring/tsconfig.json /usr/03_Modules/Monitoring/ -COPY /03_Modules/Monitoring/src /usr/03_Modules/Monitoring/src -RUN npm run build --prefix /usr/03_Modules/Monitoring -RUN cd /usr/03_Modules/Monitoring && npm pack - -# Build citrineos-reporting module -FROM base as citrineos-reporting-builder -COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ -COPY --from=citrineos-data-builder /usr/01_Data/*.tgz /usr/01_Data/ -COPY --from=citrineos-util-builder /usr/02_Util/*.tgz /usr/02_Util/ -COPY /03_Modules/Reporting/package.json /usr/03_Modules/Reporting/ -RUN npm install --ignore-scripts=true --prefix /usr/03_Modules/Reporting - -COPY /03_Modules/Reporting/tsconfig.json /usr/03_Modules/Reporting/ -COPY /03_Modules/Reporting/src /usr/03_Modules/Reporting/src -RUN npm run build --prefix /usr/03_Modules/Reporting -RUN cd /usr/03_Modules/Reporting && npm pack - -# Build citrineos-smartcharging module -FROM base as citrineos-smartcharging-builder -COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ -COPY --from=citrineos-data-builder /usr/01_Data/*.tgz /usr/01_Data/ -COPY --from=citrineos-util-builder /usr/02_Util/*.tgz /usr/02_Util/ -COPY /03_Modules/SmartCharging/package.json /usr/03_Modules/SmartCharging/ -RUN npm install --ignore-scripts=true --prefix /usr/03_Modules/SmartCharging - -COPY /03_Modules/SmartCharging/tsconfig.json /usr/03_Modules/SmartCharging/ -COPY /03_Modules/SmartCharging/src /usr/03_Modules/SmartCharging/src -RUN npm run build --prefix /usr/03_Modules/SmartCharging -RUN cd /usr/03_Modules/SmartCharging && npm pack - -# Build citrineos-transactions module -FROM base as citrineos-transactions-builder -COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ -COPY --from=citrineos-data-builder /usr/01_Data/*.tgz /usr/01_Data/ -COPY --from=citrineos-util-builder /usr/02_Util/*.tgz /usr/02_Util/ -COPY /03_Modules/Transactions/package.json /usr/03_Modules/Transactions/ -RUN npm install --ignore-scripts=true --prefix /usr/03_Modules/Transactions - -COPY /03_Modules/Transactions/tsconfig.json /usr/03_Modules/Transactions/ -COPY /03_Modules/Transactions/src /usr/03_Modules/Transactions/src -RUN npm run build --prefix /usr/03_Modules/Transactions -RUN cd /usr/03_Modules/Transactions && npm pack - -# Final stage to assemble the server -FROM base as final-stage -WORKDIR /usr/server - -# Copy .tgz files from each builder stage -COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ -COPY --from=citrineos-data-builder /usr/01_Data/*.tgz /usr/01_Data/ -COPY --from=citrineos-util-builder /usr/02_Util/*.tgz /usr/02_Util/ -COPY --from=citrineos-certificates-builder /usr/03_Modules/Certificates/*.tgz /usr/03_Modules/Certificates/ -COPY --from=citrineos-configuration-builder /usr/03_Modules/Configuration/*.tgz /usr/03_Modules/Configuration/ -COPY --from=citrineos-evdriver-builder /usr/03_Modules/EVDriver/*.tgz /usr/03_Modules/EVDriver/ -COPY --from=citrineos-monitoring-builder /usr/03_Modules/Monitoring/*.tgz /usr/03_Modules/Monitoring/ -COPY --from=citrineos-reporting-builder /usr/03_Modules/Reporting/*.tgz /usr/03_Modules/Reporting/ -COPY --from=citrineos-smartcharging-builder /usr/03_Modules/SmartCharging/*.tgz /usr/03_Modules/SmartCharging/ -COPY --from=citrineos-transactions-builder /usr/03_Modules/Transactions/*.tgz /usr/03_Modules/Transactions/ - -COPY /Server/package.json ./ -RUN npm install --ignore-scripts=true -RUN npm rebuild bcrypt --build-from-source -RUN npm rebuild deasync --build-from-source - -COPY /Server/nodemon.json ./ -COPY /Server/tsconfig.json ./ -COPY /Server/src ./src -RUN npm run build - -CMD [ "npm", "run", "start-unix:docker" ] \ No newline at end of file diff --git a/Server/init.sh b/Server/init.sh deleted file mode 100644 index 79ec6b9ec..000000000 --- a/Server/init.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -npm run install-all - -docker-compose up -d \ No newline at end of file diff --git a/Server/local.Dockerfile b/Server/local.Dockerfile new file mode 100644 index 000000000..4b64f7006 --- /dev/null +++ b/Server/local.Dockerfile @@ -0,0 +1,15 @@ +FROM node:18 + +WORKDIR /usr/local/apps/citrineos + +COPY .. . + +RUN npm run clean +RUN npm run install-all +RUN npm run build + +EXPOSE ${PORT} + +WORKDIR /usr/local/apps/citrineos/Server + +CMD ["npm", "run", "start:local-docker"] diff --git a/Server/nodemon.json b/Server/nodemon.json index 56f8c16d7..278648983 100644 --- a/Server/nodemon.json +++ b/Server/nodemon.json @@ -1,11 +1,15 @@ { - "watch": [ - "src" - ], - "ext": ".ts,.js", - "ignore": [], - "exec": "npx ts-node ./src/index.ts", - "events": { - "crash": "nodemon --delay 500ms -L" - } + "watch": [ + "src", + "../00_Base", + "../01_Data", + "../02_Util", + "../03_Modules" + ], + "ext": ".ts,.js", + "ignore": [], + "exec": "node --inspect=0.0.0.0:9229 --nolazy -r ts-node/register", + "events": { + "crash": "nodemon --delay 500ms -L" + } } \ No newline at end of file diff --git a/Server/package.json b/Server/package.json index 735ab524e..5568d73c9 100644 --- a/Server/package.json +++ b/Server/package.json @@ -5,24 +5,16 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "prepublish": "npm run build", - "install-base": "cd ../00_Base && npm install && npm run build && npm pack && cd ../Server && npm install ../00_Base/citrineos-base-1.0.0.tgz", - "install-util": "cd ../02_Util && npm install ../00_Base/citrineos-base-1.0.0.tgz && npm run build && npm pack && cd ../Server && npm install ../02_Util/citrineos-util-1.0.0.tgz", - "install-data": "cd ../01_Data && npm install ../00_Base/citrineos-base-1.0.0.tgz && npm run build && npm pack && cd ../Server && npm install ../01_Data/citrineos-data-1.0.0.tgz", - "install-certificates": "cd ../03_Modules/Certificates && npm run install-all && npm install && npm run build && npm pack && cd ../../Server && npm install ../03_Modules/Certificates/citrineos-certificates-1.0.0.tgz", - "install-configuration": "cd ../03_Modules/Configuration && npm run install-all && npm install && npm run build && npm pack && cd ../../Server && npm install ../03_Modules/Configuration/citrineos-configuration-1.0.0.tgz", - "install-evdriver": "cd ../03_Modules/EVDriver && npm run install-all && npm install && npm run build && npm pack && cd ../../Server && npm install ../03_Modules/EVDriver/citrineos-evdriver-1.0.0.tgz", - "install-monitoring": "cd ../03_Modules/Monitoring && npm run install-all && npm install && npm run build && npm pack && cd ../../Server && npm install ../03_Modules/Monitoring/citrineos-monitoring-1.0.0.tgz", - "install-reporting": "cd ../03_Modules/Reporting && npm run install-all && npm install && npm run build && npm pack && cd ../../Server && npm install ../03_Modules/Reporting/citrineos-reporting-1.0.0.tgz", - "install-smartcharging": "cd ../03_Modules/SmartCharging && npm run install-all && npm install && npm run build && npm pack && cd ../../Server && npm install ../03_Modules/Reporting/citrineos-smartcharging-1.0.0.tgz", - "install-transactions": "cd ../03_Modules/Transactions && npm run install-all && npm install && npm run build && npm pack && cd ../../Server && npm install ../03_Modules/Transactions/citrineos-transactions-1.0.0.tgz", - "install-all": "npm run install-base && npm run install-data && npm run install-util && npm run install-configuration && npm run install-evdriver && npm run install-reporting && npm run install-transactions && npm run install-monitoring", "clean-all-windows": "del package-lock.json && cd ../00_Base && del package-lock.json && rmdir lib /s /q && cd ../02_Util && del package-lock.json && rmdir lib /s /q && cd ../01_Data && del package-lock.json && rmdir lib /s /q && cd ../03_Modules/Configuration && del package-lock.json && rmdir lib /s /q && cd ../03_Modules/EVDriver && del package-lock.json && rmdir lib /s /q && cd ../03_Modules/Reporting && del package-lock.json && rmdir lib /s /q && cd ../03_Modules/Transactions && del package-lock.json && rmdir lib /s /q && cd ../03_Modules/Monitoring && del package-lock.json && rmdir lib /s /q", - "build": "tsc", "start-unix:docker": "export APP_ENV=docker && npx nodemon", - "start-unix:local": "export APP_ENV=local && npx nodemon", + "start-unix:local": "export APP_ENV=local && export CITRINEOS_DATA_SEQUELIZE_HOST=127.0.0.1 && npx nodemon", "start-windows:local": "set APP_ENV=local && RefreshEnv.cmd && npx nodemon", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "clean": "rm -rf package-lock.json dist node_modules tsconfig.tsbuildinfo", + "compile": "npm run clean && tsc -p tsconfig.json", + "start": "APP_NAME=all APP_ENV=local node --inspect=0.0.0.0:9229 dist/index.js", + "start:local-docker": "nodemon src/index.ts", + "start:local": "APP_NAME=all APP_ENV=local nodemon src/index.ts" }, "keywords": [ "ocpp", @@ -41,18 +33,17 @@ "typescript": "^5.0.4" }, "dependencies": { - "@citrineos/base": "file:../00_Base/citrineos-base-1.0.0.tgz", - "@citrineos/certificates": "file:../03_Modules/Certificates/citrineos-certificates-1.0.0.tgz", - "@citrineos/configuration": "file:../03_Modules/Configuration/citrineos-configuration-1.0.0.tgz", - "@citrineos/data": "file:../01_Data/citrineos-data-1.0.0.tgz", - "@citrineos/evdriver": "file:../03_Modules/EVDriver/citrineos-evdriver-1.0.0.tgz", - "@citrineos/monitoring": "file:../03_Modules/Monitoring/citrineos-monitoring-1.0.0.tgz", - "@citrineos/reporting": "file:../03_Modules/Reporting/citrineos-reporting-1.0.0.tgz", - "@citrineos/smartcharging": "file:../03_Modules/SmartCharging/citrineos-smartcharging-1.0.0.tgz", - "@citrineos/transactions": "file:../03_Modules/Transactions/citrineos-transactions-1.0.0.tgz", - "@citrineos/util": "file:../02_Util/citrineos-util-1.0.0.tgz", - "@fastify/swagger": "^8.10.1", - "@fastify/swagger-ui": "^1.9.3", + "@citrineos/base": "1.0.0", + "@citrineos/certificates": "1.0.0", + "@citrineos/configuration": "1.0.0", + "@citrineos/data": "1.0.0", + "@citrineos/evdriver": "1.0.0", + "@citrineos/monitoring": "1.0.0", + "@citrineos/reporting": "1.0.0", + "@citrineos/smartcharging": "1.0.0", + "@citrineos/transactions": "1.0.0", + "@citrineos/util": "1.0.0", + "@directus/extensions": "^1.0.2", "@fastify/type-provider-json-schema-to-ts": "^2.2.2", "ajv": "^8.12.0", "fastify": "^4.22.2", @@ -62,10 +53,11 @@ "ws": "^8.13.0" }, "engines": { - "node": ">=16" + "node": ">=18" }, "optionalDependencies": { "bufferutil": "^4.0.8", "utf-8-validate": "^6.0.3" - } + }, + "workspace": "../" } diff --git a/Server/redis.Dockerfile b/Server/redis.Dockerfile new file mode 100644 index 000000000..d885cff6b --- /dev/null +++ b/Server/redis.Dockerfile @@ -0,0 +1,5 @@ +FROM redis:6.2 as base + +COPY /data/redis/redis.conf /usr/local/etc/redis/ + +CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ] \ No newline at end of file diff --git a/Server/src/config/envs/docker.ts b/Server/src/config/envs/docker.ts index 1d422e8f4..05b3e33b7 100644 --- a/Server/src/config/envs/docker.ts +++ b/Server/src/config/envs/docker.ts @@ -7,6 +7,10 @@ import { RegistrationStatusEnumType, defineConfig } from "@citrineos/base"; export function createDockerConfig() { return defineConfig({ env: "development", + centralSystem: { + host: "0.0.0.0", + port: 8080 + }, modules: { certificates: { endpointPrefix: "/certificates" @@ -17,7 +21,7 @@ export function createDockerConfig() { unknownChargerStatus: RegistrationStatusEnumType.Accepted, getBaseReportOnPending: true, bootWithRejectedVariables: true, - autoAccept: false, + autoAccept: true, endpointPrefix: "/configuration" }, evdriver: { @@ -33,7 +37,8 @@ export function createDockerConfig() { endpointPrefix: "/smartcharging" }, transactions: { - endpointPrefix: "/transactions" + endpointPrefix: "/transactions", + costUpdatedInterval: 60 }, }, data: { @@ -45,7 +50,7 @@ export function createDockerConfig() { username: "citrine", password: "citrine", storage: "", - sync: true, + sync: false, } }, util: { @@ -57,33 +62,40 @@ export function createDockerConfig() { url: "amqp://guest:guest@amqp-broker:5672", exchange: "citrineos", } - } - }, - server: { - logLevel: 2, // debug - host: "0.0.0.0", - port: 8080, + }, swagger: { path: "/docs", + logoPath: "/usr/local/apps/citrineos/server/src/assets/logo.png", exposeData: true, exposeMessage: true + }, + directus: { + host: "directus", + port: 8055, + generateFlows: true + }, + networkConnection: { + websocketServers: [{ + id: "0", + securityProfile: 0, + allowUnknownChargingStations: true, + pingInterval: 60, + host: "0.0.0.0", + port: 8081, + protocol: "ocpp2.0.1" + }, { + id: "1", + securityProfile: 1, + allowUnknownChargingStations: false, + pingInterval: 60, + host: "0.0.0.0", + port: 8082, + protocol: "ocpp2.0.1" + }] } - }, - websocket: { - pingInterval: 60, - maxCallLengthSeconds: 5, - maxCachingSeconds: 10 }, - websocketServer: [{ - securityProfile: 0, - host: "0.0.0.0", - port: 8081, - protocol: "ocpp2.0.1" - },{ - securityProfile: 1, - host: "0.0.0.0", - port: 8082, - protocol: "ocpp2.0.1" - }] + logLevel: 2, // debug + maxCallLengthSeconds: 5, + maxCachingSeconds: 10 }); } \ No newline at end of file diff --git a/Server/src/config/envs/local.ts b/Server/src/config/envs/local.ts index b11483b6d..19561a8e3 100644 --- a/Server/src/config/envs/local.ts +++ b/Server/src/config/envs/local.ts @@ -7,6 +7,10 @@ import { RegistrationStatusEnumType, defineConfig } from "@citrineos/base"; export function createLocalConfig() { return defineConfig({ env: "development", + centralSystem: { + host: "0.0.0.0", + port: 8080 + }, modules: { certificates: { endpointPrefix: "/certificates" @@ -17,7 +21,7 @@ export function createLocalConfig() { unknownChargerStatus: RegistrationStatusEnumType.Accepted, getBaseReportOnPending: true, bootWithRejectedVariables: true, - autoAccept: false, + autoAccept: true, endpointPrefix: "/configuration" }, evdriver: { @@ -33,7 +37,8 @@ export function createLocalConfig() { endpointPrefix: "/smartcharging" }, transactions: { - endpointPrefix: "/transactions" + endpointPrefix: "/transactions", + costUpdatedInterval: 60 }, }, data: { @@ -45,7 +50,7 @@ export function createLocalConfig() { username: "citrine", password: "citrine", storage: "", - sync: true, + sync: false, } }, util: { @@ -57,33 +62,38 @@ export function createLocalConfig() { url: "amqp://guest:guest@localhost:5672", exchange: "citrineos", } - } - }, - server: { - logLevel: 2, // debug - host: "0.0.0.0", - port: 8080, + }, swagger: { path: "/docs", + logoPath: "/usr/server/src/assets/logo.png", exposeData: true, exposeMessage: true + }, + directus: { + generateFlows: false + }, + networkConnection: { + websocketServers: [{ + id: "0", + securityProfile: 0, + allowUnknownChargingStations: true, + pingInterval: 60, + host: "0.0.0.0", + port: 8081, + protocol: "ocpp2.0.1" + }, { + id: "1", + securityProfile: 1, + allowUnknownChargingStations: false, + pingInterval: 60, + host: "0.0.0.0", + port: 8082, + protocol: "ocpp2.0.1" + }] } }, - websocket: { - pingInterval: 60, - maxCallLengthSeconds: 5, - maxCachingSeconds: 10 - }, - websocketServer: [{ - securityProfile: 0, - host: "0.0.0.0", - port: 8081, - protocol: "ocpp2.0.1" - }, { - securityProfile: 1, - host: "0.0.0.0", - port: 8082, - protocol: "ocpp2.0.1" - }] + logLevel: 2, // debug + maxCallLengthSeconds: 5, + maxCachingSeconds: 10 }); } \ No newline at end of file diff --git a/Server/src/config/envs/swarm.docker.ts b/Server/src/config/envs/swarm.docker.ts new file mode 100644 index 000000000..b0209da7f --- /dev/null +++ b/Server/src/config/envs/swarm.docker.ts @@ -0,0 +1,117 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { RegistrationStatusEnumType, defineConfig } from "@citrineos/base"; + +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 + }, + configuration: { + heartbeatInterval: 60, + bootRetryInterval: 15, + unknownChargerStatus: RegistrationStatusEnumType.Accepted, + getBaseReportOnPending: true, + bootWithRejectedVariables: true, + autoAccept: true, + endpointPrefix: "configuration", + host: "0.0.0.0", + port: 8084 + }, + evdriver: { + endpointPrefix: "evdriver", + host: "0.0.0.0", + port: 8085 + }, + monitoring: { + endpointPrefix: "monitoring", + host: "0.0.0.0", + port: 8086 + }, + reporting: { + endpointPrefix: "reporting", + host: "0.0.0.0", + port: 8087 + }, + smartcharging: { + endpointPrefix: "smartcharging", + host: "0.0.0.0", + port: 8088 + }, + transactions: { + endpointPrefix: "transactions", + host: "0.0.0.0", + port: 8089 + }, + }, + data: { + sequelize: { + host: "ocpp-db", + port: 5432, + database: "citrine", + dialect: "postgres", + username: "citrine", + password: "citrine", + storage: "", + sync: false + } + }, + util: { + cache: { + redis: { + host: "redis", + port: 6379, + } + }, + messageBroker: { + amqp: { + url: "amqp://guest:guest@amqp-broker:5672", + exchange: "citrineos", + } + }, + swagger: { + path: "/docs", + logoPath: "/usr/local/apps/citrineos/server/src/assets/logo.png", + exposeData: true, + exposeMessage: true + }, + directus: { + host: "directus", + port: 8055, + generateFlows: true + }, + networkConnection: { + websocketServers: [{ + id: "0", + securityProfile: 0, + allowUnknownChargingStations: true, + pingInterval: 60, + host: "0.0.0.0", + port: 8081, + protocol: "ocpp2.0.1" + }, { + id: "1", + securityProfile: 1, + allowUnknownChargingStations: false, + pingInterval: 60, + host: "0.0.0.0", + port: 8082, + protocol: "ocpp2.0.1" + }] + } + }, + logLevel: 2, // debug + maxCallLengthSeconds: 5, + maxCachingSeconds: 10 + }); +} \ No newline at end of file diff --git a/Server/src/config/index.ts b/Server/src/config/index.ts index 08da2c45c..4c03ec087 100644 --- a/Server/src/config/index.ts +++ b/Server/src/config/index.ts @@ -3,9 +3,9 @@ // // SPDX-License-Identifier: Apache 2.0 -import { SystemConfig } from "@citrineos/base"; -import { createLocalConfig } from "./envs/local"; -import { createDockerConfig } from "./envs/docker"; +import {SystemConfig} from "@citrineos/base"; +import {createLocalConfig} from "./envs/local"; +import {createDockerConfig} from "./envs/docker"; export const systemConfig: SystemConfig = getConfig(); diff --git a/Server/src/index.ts b/Server/src/index.ts index 6c5b995e8..c8463ce59 100644 --- a/Server/src/index.ts +++ b/Server/src/index.ts @@ -3,171 +3,358 @@ // // SPDX-License-Identifier: Apache 2.0 -import { ICache, ICentralSystem, IMessageHandler, IMessageSender, IModule, IModuleApi, SystemConfig } from '@citrineos/base'; -import { MonitoringModule, MonitoringModuleApi } from '@citrineos/monitoring'; -import { MemoryCache, RabbitMqReceiver, RabbitMqSender } from '@citrineos/util'; -import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; -import Ajv from "ajv"; -import addFormats from "ajv-formats" -import fastify, { FastifyInstance } from 'fastify'; -import { ILogObj, Logger } from 'tslog'; -import { systemConfig } from './config'; -import { CentralSystemImpl } from './server/server'; -import { initSwagger } from './util/swagger'; -import { ConfigurationModule, ConfigurationModuleApi } from '@citrineos/configuration'; -import { TransactionsModule, TransactionsModuleApi } from '@citrineos/transactions'; -import { CertificatesModule, CertificatesModuleApi } from '@citrineos/certificates'; -import { EVDriverModule, EVDriverModuleApi } from '@citrineos/evdriver'; -import { ReportingModule, ReportingModuleApi } from '@citrineos/reporting'; -import { SmartChargingModule, SmartChargingModuleApi } from '@citrineos/smartcharging'; -import { sequelize } from '@citrineos/data'; - -class CitrineOSServer { - - /** - * Fields - */ - private _config: SystemConfig; - private _modules: Array; - private _apis: Array; - private _centralSystem: ICentralSystem; - private _logger: Logger; - private _server: FastifyInstance; - private _cache: ICache; - private _ajv: Ajv; - - /** - * Constructor for the class. - * - * @param {FastifyInstance} server - optional Fastify server instance - * @param {Ajv} ajv - optional Ajv JSON schema validator instance - */ - constructor(config: SystemConfig, server?: FastifyInstance, ajv?: Ajv, cache?: ICache) { - - // Set system config - // TODO: Create and export config schemas for each util module, such as amqp, redis, kafka, etc, to avoid passing them possibly invalid configuration - if (!config.util.messageBroker.amqp) { - throw new Error("This server implementation requires amqp configuration for rabbitMQ."); - } - this._config = config; +import { + type AbstractModule, + type AbstractModuleApi, + EventGroup, + eventGroupFromString, + type IAuthenticator, + type ICache, + type IMessageHandler, + type IMessageSender, + type IModule, + type IModuleApi, + type SystemConfig +} from '@citrineos/base' +import {MonitoringModule, MonitoringModuleApi} from '@citrineos/monitoring' +import { + Authenticator, + DirectusUtil, + initSwagger, + MemoryCache, + RabbitMqReceiver, + RabbitMqSender, + RedisCache, + WebsocketNetworkConnection +} from '@citrineos/util' +import {type JsonSchemaToTsProvider} from '@fastify/type-provider-json-schema-to-ts' +import Ajv from 'ajv' +import addFormats from 'ajv-formats' +import fastify, {type FastifyInstance} from 'fastify' +import {type ILogObj, Logger} from 'tslog' +import {systemConfig} from './config' +import {ConfigurationModule, ConfigurationModuleApi} from '@citrineos/configuration' +import {TransactionsModule, TransactionsModuleApi} from '@citrineos/transactions' +import {CertificatesModule, CertificatesModuleApi} from '@citrineos/certificates' +import {EVDriverModule, EVDriverModuleApi} from '@citrineos/evdriver' +import {ReportingModule, ReportingModuleApi} from '@citrineos/reporting' +import {SmartChargingModule, SmartChargingModuleApi} from '@citrineos/smartcharging' +import {sequelize} from '@citrineos/data' +import { + type FastifyRouteSchemaDef, + type FastifySchemaCompiler, + type FastifyValidationResult +} from 'fastify/types/schema' +import {AdminApi, MessageRouterImpl} from '@citrineos/ocpprouter' - // Create server instance - this._server = server || fastify().withTypeProvider(); +interface ModuleConfig { + ModuleClass: new (...args: any[]) => AbstractModule + ModuleApiClass: new (...args: any[]) => AbstractModuleApi + configModule: any // todo type? +} - // Add health check - this._server.get('/health', async () => { - return { status: 'healthy' }; - }); +export class CitrineOSServer { + /** + * Fields + */ + private readonly _config: SystemConfig + private readonly _logger: Logger + private readonly _server: FastifyInstance + private readonly _cache: ICache + private readonly _ajv: Ajv + private readonly modules: IModule[] = [] + private readonly apis: IModuleApi[] = [] + private host?: string + private port?: number + private eventGroup?: EventGroup + private _authenticator?: IAuthenticator + private _networkConnection?: WebsocketNetworkConnection - // Create Ajv JSON schema validator instance - this._ajv = ajv || new Ajv({ removeAdditional: "all", useDefaults: true, coerceTypes: "array", strict: false }); - addFormats(this._ajv, { mode: "fast", formats: ["date-time"] }); + /** + * Constructor for the class. + * + * @param {EventGroup} appName - app type + * @param {SystemConfig} config - config + * @param {FastifyInstance} server - optional Fastify server instance + * @param {Ajv} ajv - optional Ajv JSON schema validator instance + */ + // todo rename event group to type + constructor(appName: string, config: SystemConfig, server?: FastifyInstance, ajv?: Ajv, cache?: ICache) { + // Set system config + // TODO: Create and export config schemas for each util module, such as amqp, redis, kafka, etc, to avoid passing them possibly invalid configuration + if (!config.util.messageBroker.amqp) { + throw new Error('This server implementation requires amqp configuration for rabbitMQ.') + } + this._config = config - // Initialize parent logger - this._logger = new Logger({ - name: "CitrineOS Logger", - minLevel: systemConfig.server.logLevel, - hideLogPositionForProduction: systemConfig.env === "production" - }); + // Create server instance + this._server = server || fastify().withTypeProvider() - // Set cache implementation - this._cache = cache || new MemoryCache(); + // Add health check + this.initHealthCheck() - // Initialize Swagger if enabled - if (this._config.server.swagger) { - initSwagger(this._config, this._server); - } + // Create Ajv JSON schema validator instance + this._ajv = this.initAjv(ajv) + this.addAjvFormats() - // Register AJV for schema validation - this._server.setValidatorCompiler(({ schema, method, url, httpPart }) => { - return this._ajv.compile(schema); - }); - - // Force sync database - sequelize.DefaultSequelizeInstance.getInstance(this._config, this._logger, true); - - this._centralSystem = new CentralSystemImpl(this._config, this._cache, undefined, undefined, this._logger, ajv); - - // Initialize modules & APIs - // Always initialize APIs after SwaggerUI - const configurationModule = new ConfigurationModule(this._config, this._cache, this._createSender(), this._createHandler(), this._logger); - const evdriverModule = new EVDriverModule(this._config, this._cache, this._createSender(), this._createHandler(), this._logger); - const monitoringModule = new MonitoringModule(this._config, this._cache, this._createSender(), this._createHandler(), this._logger); - const reportingModule = new ReportingModule(this._config, this._cache, this._createSender(), this._createHandler(), this._logger); - const transactionsModule = new TransactionsModule(this._config, this._cache, this._createSender(), this._createHandler(), this._logger); - this._modules = [ - configurationModule, - evdriverModule, - monitoringModule, - reportingModule, - transactionsModule - ] - this._apis = [ - new ConfigurationModuleApi(configurationModule, this._server, this._logger), - new EVDriverModuleApi(evdriverModule, this._server, this._logger), - new MonitoringModuleApi(monitoringModule, this._server, this._logger), - new ReportingModuleApi(reportingModule, this._server, this._logger), - new TransactionsModuleApi(transactionsModule, this._server, this._logger), - ]; - if (this._config.modules.certificates) { - const certificatesModule = new CertificatesModule(this._config, this._cache, this._createSender(), this._createHandler(), this._logger) - this._modules.push(certificatesModule); - this._apis.push(new CertificatesModuleApi(certificatesModule, this._server, this._logger)); - } - if (this._config.modules.smartcharging) { - const smartchargingModule = new SmartChargingModule(this._config, this._cache, this._createSender(), this._createHandler(), this._logger) - this._modules.push(smartchargingModule); - this._apis.push(new SmartChargingModuleApi(smartchargingModule, this._server, this._logger)); - } + // Initialize parent logger + this._logger = this.initLogger() + + // Force sync database + this.forceDbSync() + + // Set cache implementation + this._cache = this.initCache(cache) - process.on('SIGINT', this.shutdown.bind(this)); - process.on('SIGTERM', this.shutdown.bind(this)); - process.on('SIGQUIT', this.shutdown.bind(this)); + // Initialize Swagger if enabled + this.initSwagger() + + // Add Directus Message API flow creation if enabled + if (this._config.util.directus?.generateFlows) { + const directusUtil = new DirectusUtil(this._config, this._logger) + this._server.addHook('onRoute', directusUtil.addDirectusMessageApiFlowsFastifyRouteHook.bind(directusUtil)) + this._server.addHook('onReady', async () => { + this._logger?.info('Directus actions initialization finished') + }) } - protected _createSender(): IMessageSender { - return new RabbitMqSender(this._config, this._logger); + // Register AJV for schema validation + this.registerAjv() + + // Initialize module & API + // Always initialize API after SwaggerUI + this.initSystem(appName) + + process.on('SIGINT', this.shutdown.bind(this)) + process.on('SIGTERM', this.shutdown.bind(this)) + process.on('SIGQUIT', this.shutdown.bind(this)) + } + + private initHealthCheck() { + this._server.get('/health', async () => { + return {status: 'healthy'} + }) + } + + private initAjv(ajv?: Ajv) { + return ajv || new Ajv({ + removeAdditional: 'all', + useDefaults: true, + coerceTypes: 'array', + strict: false + }) + } + + private addAjvFormats() { + addFormats(this._ajv, { + mode: 'fast', + formats: ['date-time'] + }) + } + + private initLogger() { + return new Logger({ + name: 'CitrineOS Logger', + minLevel: systemConfig.logLevel, + hideLogPositionForProduction: systemConfig.env === 'production', + // Disable colors for cloud deployment as some cloude logging environments such as cloudwatch can not interpret colors + stylePrettyLogs: process.env.DEPLOYMENT_TARGET !== 'cloud' + }) + } + + private forceDbSync() { + sequelize.DefaultSequelizeInstance.getInstance(this._config, this._logger, true) + } + + private initCache(cache?: ICache): ICache { + return cache || (this._config.util.cache.redis + ? new RedisCache({ + socket: { + host: this._config.util.cache.redis.host, + port: this._config.util.cache.redis.port + } + }) + : new MemoryCache()) + } + + private initSwagger() { + if (this._config.util.swagger) { + initSwagger(this._config, this._server) } + } - protected _createHandler(): IMessageHandler { - return new RabbitMqReceiver(this._config, this._logger); + private registerAjv() { + // todo type schema instead of any + const fastifySchemaCompiler: FastifySchemaCompiler = (routeSchema: FastifyRouteSchemaDef) => { + return this._ajv?.compile(routeSchema.schema) as FastifyValidationResult } + this._server.setValidatorCompiler(fastifySchemaCompiler) + } - shutdown() { + private initNetworkConnection() { + this._authenticator = new Authenticator(this._cache, new sequelize.LocationRepository(this._config, this._logger), new sequelize.DeviceModelRepository(this._config, this._logger), this._logger) - // Shut down all modules and central system - this._modules.forEach(module => { - module.shutdown(); - }); - this._centralSystem.shutdown(); + const router = new MessageRouterImpl(this._config, this._cache, this._createSender(), this._createHandler(), async (identifier: string, message: string) => false, this._logger, this._ajv) - // Shutdown server - this._server.close(); + this._networkConnection = new WebsocketNetworkConnection(this._config, this._cache, this._authenticator, router, this._logger) - setTimeout(() => { - console.log("Exiting..."); - process.exit(1); - }, 2000); + this.apis.push(new AdminApi(router, this._server, this._logger)); + + this.host = this._config.centralSystem.host; + this.port = this._config.centralSystem.port; + } + + private initAllModules() { + [ + this.getModuleConfig(EventGroup.Certificates), + this.getModuleConfig(EventGroup.Configuration), + this.getModuleConfig(EventGroup.EVDriver), + this.getModuleConfig(EventGroup.Monitoring), + this.getModuleConfig(EventGroup.Reporting), + this.getModuleConfig(EventGroup.SmartCharging), + this.getModuleConfig(EventGroup.Transactions) + ].forEach(moduleConfig => { + this.initModule(moduleConfig) + }) + } + + private initModule(moduleConfig: ModuleConfig) { + if (moduleConfig.configModule !== null) { + const module = new moduleConfig.ModuleClass( + this._config, + this._cache, + this._createSender(), + this._createHandler(), + this._logger + ) + this.modules.push(module) + this.apis.push( + new moduleConfig.ModuleApiClass( + module, + this._server, + this._logger + ) + ) + // TODO: take actions to make sure module has correct subscriptions and log proof + this._logger?.info(`${moduleConfig.ModuleClass.name} module started...`) + if (this.eventGroup !== EventGroup.All) { + this.host = moduleConfig.configModule.host as string + this.port = moduleConfig.configModule.port as number + } + } else { + throw new Error(`No config for ${this.eventGroup} module`) } + } - run(): Promise { - try { - return this._server.listen({ - port: this._config.server.port, - host: this._config.server.host - }).then(address => { - this._logger.info(`Server listening at ${address}`); - }).catch(error => { - this._logger.error(error); - process.exit(1); - }); - } catch (error) { - return Promise.reject(error); + private getModuleConfig(appName: EventGroup): ModuleConfig { + switch (appName) { + case EventGroup.Certificates: + return { + ModuleClass: CertificatesModule, + ModuleApiClass: CertificatesModuleApi, + configModule: this._config.modules.certificates + } + case EventGroup.Configuration: + return { + ModuleClass: ConfigurationModule, + ModuleApiClass: ConfigurationModuleApi, + configModule: this._config.modules.configuration + } + case EventGroup.EVDriver: + return { + ModuleClass: EVDriverModule, + ModuleApiClass: EVDriverModuleApi, + configModule: this._config.modules.evdriver + } + case EventGroup.Monitoring: + return { + ModuleClass: MonitoringModule, + ModuleApiClass: MonitoringModuleApi, + configModule: this._config.modules.monitoring + } + case EventGroup.Reporting: + return { + ModuleClass: ReportingModule, + ModuleApiClass: ReportingModuleApi, + configModule: this._config.modules.reporting + } + case EventGroup.SmartCharging: + return { + ModuleClass: SmartChargingModule, + ModuleApiClass: SmartChargingModuleApi, + configModule: this._config.modules.smartcharging + } + case EventGroup.Transactions: + return { + ModuleClass: TransactionsModule, + ModuleApiClass: TransactionsModuleApi, + configModule: this._config.modules.transactions } + default: + throw new Error('Unhandled module type: ' + appName) + } + } + + private initSystem(appName: string) { + this.eventGroup = eventGroupFromString(appName) + if (this.eventGroup === EventGroup.All) { + this.initNetworkConnection() + this.initAllModules() + } else if (this.eventGroup === EventGroup.General) { + this.initNetworkConnection() + } else { + const moduleConfig: ModuleConfig = this.getModuleConfig(this.eventGroup) + this.initModule(moduleConfig) + } + } + + protected _createSender(): IMessageSender { + return new RabbitMqSender(this._config, this._logger) + } + + protected _createHandler(): IMessageHandler { + return new RabbitMqReceiver(this._config, this._logger) + } + + shutdown() { + // todo shut down depending on setup + // Shut down all modules and central system + this.modules.forEach(module => { + module.shutdown() + }) + this._networkConnection?.shutdown() + + // Shutdown server + this._server.close().then() // todo async? + + setTimeout(() => { + console.log('Exiting...') + process.exit(1) + }, 2000) + } + + async run(): Promise { + try { + 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) } + } } -new CitrineOSServer(systemConfig).run().catch(error => { - console.error(error); - process.exit(1); -}); \ No newline at end of file +new CitrineOSServer( + process.env.APP_NAME as EventGroup, + systemConfig +).run().catch((error: any) => { + console.error(error) + process.exit(1) +}) diff --git a/Server/src/server/connection.ts b/Server/src/server/connection.ts deleted file mode 100644 index 2e7027744..000000000 --- a/Server/src/server/connection.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2023 S44, LLC -// Copyright Contributors to the CitrineOS Project -// -// SPDX-License-Identifier: Apache 2.0 - -import { IClientConnection } from "@citrineos/base"; - -/** - * Implementation of the client connection - */ -export class ClientConnectionImpl implements IClientConnection { - - /** - * Fields - */ - - private _identifier: string; - private _sessionIndex: string; - private _ip: string; - private _port: number; - private _isAlive: boolean; - - /** - * Constructor - */ - - constructor(identifier: string, sessionIndex: string, ip: string, port: number) { - this._identifier = identifier; - this._sessionIndex = sessionIndex; - this._ip = ip; - this._port = port; - this._isAlive = false; - } - - /** - * Properties - */ - - get identifier(): string { - return this._identifier; - } - - get sessionIndex(): string { - return this._sessionIndex; - } - - get ip(): string { - return this._ip; - } - - get port(): number { - return this._port; - } - - get isAlive(): boolean { - return this._isAlive; - } - - set isAlive(value: boolean) { - this._isAlive = value; - } - - get connectionUrl(): string { - return `ws://${this._ip}:${this._port}/${this._identifier}`; - } -} \ No newline at end of file diff --git a/Server/src/server/router.ts b/Server/src/server/router.ts deleted file mode 100644 index b3a03460a..000000000 --- a/Server/src/server/router.ts +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright (c) 2023 S44, LLC -// Copyright Contributors to the CitrineOS Project -// -// SPDX-License-Identifier: Apache 2.0 - -import { Call, CallAction, CallError, CallResult, EventGroup, ICache, ICentralSystem, IClientConnection, IMessage, IMessageConfirmation, IMessageContext, IMessageHandler, IMessageRouter, IMessageSender, LOG_LEVEL_OCPP, MessageOrigin, MessageState, MessageTypeId, OcppError, OcppRequest, OcppResponse, SystemConfig } from "@citrineos/base"; -import { RabbitMqReceiver } from "@citrineos/util"; -import { ILogObj, Logger } from "tslog"; - -const logger = new Logger({ name: "OCPPMessageRouter" }); - -/** - * Implementation of a message handler utilizing {@link RabbitMqReceiver} as the underlying transport. - */ -export class CentralSystemMessageHandler extends RabbitMqReceiver { - - /** - * Fields - */ - - private _centralSystem: ICentralSystem; - - /** - * Constructor - * - * @param centralSystem Central system implementation to use - */ - - constructor(systemConfig: SystemConfig, centralSystem: ICentralSystem, logger?: Logger) { - super(systemConfig, logger); - this._centralSystem = centralSystem; - } - - /** - * Methods - */ - - async handle(message: IMessage, context?: IMessageContext): Promise { - - logger.debug("Received message:", message); - - if (message.state === MessageState.Response) { - if (message.payload instanceof OcppError) { - let callError = (message.payload as OcppError).asCallError(); - await this._centralSystem.sendCallError(message.context.stationId, callError); - } else { - let callResult = [MessageTypeId.CallResult, message.context.correlationId, message.payload] as CallResult; - await this._centralSystem.sendCallResult(message.context.stationId, callResult); - } - } else if (message.state === MessageState.Request) { - let call = [MessageTypeId.Call, message.context.correlationId, message.action, message.payload] as Call; - await this._centralSystem.sendCall(message.context.stationId, call); - } - } -} - -export class OcppMessageRouter implements IMessageRouter { - - public readonly CALLBACK_URL_CACHE_PREFIX: string = "CALLBACK_URL_"; - - private _cache: ICache; - private _sender: IMessageSender; - private _handler: IMessageHandler; - - constructor(cache: ICache, sender: IMessageSender, handler: IMessageHandler) { - this._cache = cache; - this._sender = sender; - this._handler = handler; - } - - async registerConnection(client: IClientConnection): Promise { - const requestSubscription = await this.handler.subscribe(client.identifier, undefined, { - stationId: client.identifier, - state: MessageState.Request.toString(), - origin: MessageOrigin.CentralSystem.toString() - }); - - const responseSubscription = await this.handler.subscribe(client.identifier, undefined, { - stationId: client.identifier, - state: MessageState.Response.toString(), - origin: MessageOrigin.ChargingStation.toString() - }); - - return requestSubscription && responseSubscription; - } - - routeCall(client: IClientConnection, message: Call): Promise { - let messageId = message[1]; - let action = message[2] as CallAction; - let payload = message[3] as OcppRequest; - - // TODO: Add tenantId to context - let context: IMessageContext = { correlationId: messageId, stationId: client.identifier, tenantId: '' }; - - // TODO: Use base util builder instead - const _message: IMessage = { - origin: MessageOrigin.ChargingStation, - eventGroup: EventGroup.General, // TODO: Change to appropriate event group - action, - state: MessageState.Request, - context, - payload - }; - - return this._sender.send(_message); - } - - routeCallResult(client: IClientConnection, message: CallResult, action: CallAction): Promise { - let messageId = message[1]; - let payload = message[2] as OcppResponse; - - // TODO: Add tenantId to context - let context: IMessageContext = { correlationId: messageId, stationId: client.identifier, tenantId: '' }; - - const _message: IMessage = { - origin: MessageOrigin.CentralSystem, - eventGroup: EventGroup.General, - action, - state: MessageState.Response, - context, - payload - }; - - return this._sender.send(_message); - } - - routeCallError(client: IClientConnection, message: CallError, action: CallAction): Promise { - let messageId = message[1]; - let payload = new OcppError(messageId, message[2], message[3], message[4]); - - // TODO: Add tenantId to context - let context: IMessageContext = { correlationId: messageId, stationId: client.identifier, tenantId: '' }; - - const _message: IMessage = { - origin: MessageOrigin.CentralSystem, - eventGroup: EventGroup.General, - action, - state: MessageState.Response, - context, - payload - }; - - // Fulfill callback for api, if needed - this.handleMessageApiCallback(_message); - - // No error routing currently done - throw new Error('Method not implemented.'); - } - - async handleMessageApiCallback(message: IMessage): Promise { - const url: string | null = await this._cache.get(message.context.correlationId, this.CALLBACK_URL_CACHE_PREFIX + message.context.stationId); - if (url) { - await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(message.payload) - }); - } - } - - get sender(): IMessageSender { - return this._sender; - } - - get handler(): IMessageHandler { - return this._handler; - } -} \ No newline at end of file diff --git a/Server/src/server/server.ts b/Server/src/server/server.ts deleted file mode 100644 index 3ccab2f66..000000000 --- a/Server/src/server/server.ts +++ /dev/null @@ -1,697 +0,0 @@ -// Copyright (c) 2023 S44, LLC -// Copyright Contributors to the CitrineOS Project -// -// SPDX-License-Identifier: Apache 2.0 - -import { AbstractCentralSystem, AttributeEnumType, CacheNamespace, Call, CallAction, CallError, CallResult, ClientConnection, ErrorCode, ICache, ICentralSystem, IClientConnection, IMessageHandler, IMessageRouter, IMessageSender, MessageTriggerEnumType, MessageTypeId, OcppError, RegistrationStatusEnumType, RetryMessageError, SetVariableStatusEnumType, SystemConfig, TriggerMessageRequest } from "@citrineos/base"; -import { RabbitMqSender } from "@citrineos/util"; -import Ajv from "ajv"; -import * as bcrypt from "bcrypt"; -import { instanceToPlain } from "class-transformer"; -import * as https from "https"; -import * as http from "http"; -import fs from "fs"; -import { ILogObj, Logger } from "tslog"; -import { v4 as uuidv4 } from "uuid"; -import { ErrorEvent, MessageEvent, WebSocket, WebSocketServer } from "ws"; -import { CentralSystemMessageHandler, OcppMessageRouter } from "./router"; -import { DeviceModelRepository } from "@citrineos/data/lib/layers/sequelize"; -import { Duplex } from "stream"; -import { ConfigurationModule } from "@citrineos/configuration"; - -/** - * Implementation of the central system - */ -export class CentralSystemImpl extends AbstractCentralSystem implements ICentralSystem { - - /** - * Fields - */ - - protected _cache: ICache; - private _router: IMessageRouter; - private _connections: Map = new Map(); - private _httpServers: (http.Server | https.Server)[]; - private _deviceModelRepository: DeviceModelRepository; - - /** - * Constructor for the class. - * - * @param {SystemConfig} config - the system configuration - * @param {ICache} cache - the cache object - * @param {IMessageSender} [sender] - the message sender (optional) - * @param {IMessageHandler} [handler] - the message handler (optional) - * @param {Logger} [logger] - the logger object (optional) - * @param {Ajv} [ajv] - the Ajv object (optional) - */ - constructor( - config: SystemConfig, - cache: ICache, - sender?: IMessageSender, - handler?: CentralSystemMessageHandler, - logger?: Logger, - ajv?: Ajv, - deviceModelRepository?: DeviceModelRepository) { - super(config, logger, cache, ajv); - - // Initialize router before socket server to avoid race condition - this._router = new OcppMessageRouter(cache, - sender || new RabbitMqSender(config, logger), - handler || new CentralSystemMessageHandler(config, this, logger)); - - this._cache = cache; - - this._deviceModelRepository = deviceModelRepository || new DeviceModelRepository(this._config, this._logger); - - this._httpServers = []; - this._config.websocketServer.forEach(wsServer => { - let _httpServer; - switch (wsServer.securityProfile) { - case 3: // mTLS - _httpServer = https.createServer({ - key: fs.readFileSync(this._config.websocketSecurity?.tlsKeysFilepath as string), - cert: fs.readFileSync(this._config.websocketSecurity?.tlsCertificateChainFilepath as string), - ca: fs.readFileSync(this._config.websocketSecurity?.mtlsCertificateAuthorityRootsFilepath as string), - requestCert: true, - rejectUnauthorized: true - }, this._onHttpRequest.bind(this)); - break; - case 2: // TLS - _httpServer = https.createServer({ - key: fs.readFileSync(this._config.websocketSecurity?.tlsKeysFilepath as string), - cert: fs.readFileSync(this._config.websocketSecurity?.tlsCertificateChainFilepath as string) - }, this._onHttpRequest.bind(this)); - break; - case 1: - case 0: - default: // No TLS - _httpServer = http.createServer(this._onHttpRequest.bind(this)); - break; - } - - let _socketServer = new WebSocketServer({ - noServer: true, - handleProtocols: (protocols, req) => this._handleProtocols(protocols, req, wsServer.protocol), - clientTracking: false - }); - - _socketServer.on('connection', (ws: WebSocket, req: http.IncomingMessage) => this._onConnection(ws, req)); - _socketServer.on('error', (wss: WebSocketServer, error: Error) => this._onError(wss, error)); - _socketServer.on('close', (wss: WebSocketServer) => this._onClose(wss)); - - _httpServer.on('upgrade', (request, socket, head) => - this._upgradeRequest(request, socket, head, _socketServer, wsServer.securityProfile)); - _httpServer.on('error', (error) => _socketServer.emit('error', error)); - // socketServer.close() will not do anything; use httpServer.close() - _httpServer.on('close', () => _socketServer.emit('close')); - const protocol = wsServer.securityProfile > 1 ? 'wss' : 'ws'; - _httpServer.listen(wsServer.port, wsServer.host, () => { - this._logger.info(`WebsocketServer running on ${protocol}://${wsServer.host}:${wsServer.port}/`) - }); - this._httpServers.push(_httpServer); - }); - } - - /** - * Interface implementation - */ - - shutdown(): void { - this._router.sender.shutdown(); - this._router.handler.shutdown(); - this._httpServers.forEach(server => server.close()); - } - - /** - * Handles an incoming Call message from a client connection. - * - * @param {IClientConnection} connection - The client connection object. - * @param {Call} message - The Call message received. - * @return {void} - */ - onCall(connection: IClientConnection, message: Call): void { - const messageId = message[1]; - const action = message[2] as CallAction; - const payload = message[3]; - - this._onCallIsAllowed(action, connection.identifier) - .then((isAllowed: boolean) => { - if (!isAllowed) { - throw new OcppError(messageId, ErrorCode.SecurityError, `Action ${action} not allowed`); - } else { - // Run schema validation for incoming Call message - return this._validateCall(connection.identifier, message); - } - }).then(({ isValid, errors }) => { - if (!isValid || errors) { - throw new OcppError(messageId, ErrorCode.FormatViolation, "Invalid message format", { errors: errors }); - } - // Ensure only one call is processed at a time - return this._cache.setIfNotExist(connection.identifier, `${action}:${messageId}`, CacheNamespace.Transactions, this._config.websocket.maxCallLengthSeconds); - }).catch(error => { - if (error instanceof OcppError) { - this.sendCallError(connection.identifier, error.asCallError()); - } - }).then(successfullySet => { - if (!successfullySet) { - throw new OcppError(messageId, ErrorCode.RpcFrameworkError, "Call already in progress", {}); - } - // Route call - return this._router.routeCall(connection, message); - }).then(confirmation => { - if (!confirmation.success) { - throw new OcppError(messageId, ErrorCode.InternalError, 'Call failed', { details: confirmation.payload }); - } - }).catch(error => { - if (error instanceof OcppError) { - this.sendCallError(connection.identifier, error.asCallError()); - this._cache.remove(connection.identifier, CacheNamespace.Transactions); - } - }); - } - - /** - * Handles a CallResult made by the client. - * - * @param {IClientConnection} connection - The client connection that made the call. - * @param {CallResult} message - The OCPP CallResult message. - * @return {void} - */ - onCallResult(connection: IClientConnection, message: CallResult): void { - const messageId = message[1]; - const payload = message[2]; - - this._logger.debug("Process CallResult", connection.identifier, messageId, payload); - - this._cache.get(connection.identifier, CacheNamespace.Transactions) - .then(cachedActionMessageId => { - this._cache.remove(connection.identifier, CacheNamespace.Transactions); // Always remove pending call transaction - if (!cachedActionMessageId) { - throw new OcppError(messageId, ErrorCode.InternalError, "MessageId not found, call may have timed out", { "maxCallLengthSeconds": this._config.websocket.maxCallLengthSeconds }); - } - const [actionString, cachedMessageId] = cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId - if (messageId !== cachedMessageId) { - throw new OcppError(messageId, ErrorCode.InternalError, "MessageId doesn't match", { "expectedMessageId": cachedMessageId }); - } - const action: CallAction = CallAction[actionString as keyof typeof CallAction]; // Parse CallAction - return { action, ...this._validateCallResult(connection.identifier, action, message) }; // Run schema validation for incoming CallResult message - }).then(({ action, isValid, errors }) => { - if (!isValid || errors) { - throw new OcppError(messageId, ErrorCode.FormatViolation, "Invalid message format", { errors: errors }); - } - // Route call result - return this._router.routeCallResult(connection, message, action); - }).then(confirmation => { - if (!confirmation.success) { - throw new OcppError(messageId, ErrorCode.InternalError, 'CallResult failed', { details: confirmation.payload }); - } - }).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 result: ", error); - }); - } - - /** - * Handles the CallError that may have occured during a Call exchange. - * - * @param {IClientConnection} connection - The client connection object. - * @param {CallError} message - The error message. - * @return {void} This function doesn't return anything. - */ - onCallError(connection: IClientConnection, message: CallError): void { - - const messageId = message[1]; - - this._logger.debug("Process CallError", connection.identifier, message); - - this._cache.get(connection.identifier, CacheNamespace.Transactions) - .then(cachedActionMessageId => { - this._cache.remove(connection.identifier, CacheNamespace.Transactions); // Always remove pending call transaction - if (!cachedActionMessageId) { - throw new OcppError(messageId, ErrorCode.InternalError, "MessageId not found, call may have timed out", { "maxCallLengthSeconds": this._config.websocket.maxCallLengthSeconds }); - } - const [actionString, cachedMessageId] = cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId - if (messageId !== cachedMessageId) { - throw new OcppError(messageId, ErrorCode.InternalError, "MessageId doesn't match", { "expectedMessageId": cachedMessageId }); - } - const action: CallAction = CallAction[actionString as keyof typeof CallAction]; // Parse CallAction - return this._router.routeCallError(connection, message, action); - }).then(confirmation => { - if (!confirmation.success) { - throw new OcppError(messageId, ErrorCode.InternalError, 'CallError failed', { details: confirmation.payload }); - } - }).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); - }); - } - - /** - * 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} A promise that resolves to a boolean indicating if the call was sent successfully. - */ - async sendCall(identifier: string, message: Call): Promise { - const messageId = message[1]; - const action = message[2] as CallAction; - if (await this._sendCallIsAllowed(identifier, message)) { - if (await this._cache.setIfNotExist(identifier, `${action}:${messageId}`, - CacheNamespace.Transactions, this._config.websocket.maxCallLengthSeconds)) { - // Intentionally removing NULL values from object for OCPP conformity - const rawMessage = JSON.stringify(message, (k, v) => v ?? undefined); - return this._sendMessage(identifier, rawMessage); - } else { - this._logger.info("Call already in progress, throwing retry exception", identifier, message); - throw new RetryMessageError("Call already in progress"); - } - } else { - this._logger.info("RegistrationStatus Rejected, unable to send", identifier, message); - return 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} A promise that resolves to true if the call result was sent successfully, or false otherwise. - */ - async sendCallResult(identifier: string, message: CallResult): Promise { - const messageId = message[1]; - const cachedActionMessageId = await this._cache.get(identifier, CacheNamespace.Transactions); - if (!cachedActionMessageId) { - this._logger.error("Failed to send callResult due to missing message id", identifier, message); - return false; - } - let [cachedAction, cachedMessageId] = cachedActionMessageId?.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId - if (cachedMessageId === messageId) { - // Intentionally removing NULL values from object for OCPP conformity - const rawMessage = JSON.stringify(message, (k, v) => v ?? undefined); - return Promise.all([ - this._sendMessage(identifier, rawMessage), - this._cache.remove(identifier, CacheNamespace.Transactions) - ]).then(successes => successes.every(Boolean)); - } else { - this._logger.error("Failed to send callResult due to mismatch in message id", identifier, cachedActionMessageId, message); - return 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} - A promise that resolves to true if the message was sent successfully. - */ - async sendCallError(identifier: string, message: CallError): Promise { - const messageId = message[1]; - const cachedActionMessageId = await this._cache.get(identifier, CacheNamespace.Transactions); - if (!cachedActionMessageId) { - this._logger.error("Failed to send callError due to missing message id", identifier, message); - return false; - } - let [cachedAction, cachedMessageId] = cachedActionMessageId?.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId - if (cachedMessageId === messageId) { - // Intentionally removing NULL values from object for OCPP conformity - const rawMessage = JSON.stringify(message, (k, v) => v ?? undefined); - return Promise.all([ - this._sendMessage(identifier, rawMessage), - this._cache.remove(identifier, CacheNamespace.Transactions) - ]).then(successes => successes.every(Boolean)); - } else { - this._logger.error("Failed to send callError due to mismatch in message id", identifier, cachedActionMessageId, message); - return false; - } - } - - /** - * Methods - */ - - /** - * 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} A promise that resolves to a boolean indicating if the action and identifier are allowed. - */ - private _onCallIsAllowed(action: CallAction, identifier: string): Promise { - return this._cache.exists(action, identifier).then(blacklisted => !blacklisted); - } - - /** - * Internal method to send a message to the charging station specified by the identifier. - * - * @param {string} identifier - The identifier of the client. - * @param {string} message - The message to send. - * @return {void} This function does not return anything. - */ - private _sendMessage(identifier: string, message: string): Promise { - return this._getClientConnection(identifier).then(clientConnection => { - if (clientConnection) { - const websocketConnection = this._connections.get(identifier); - if (websocketConnection && websocketConnection.readyState === WebSocket.OPEN) { - websocketConnection.send(message, (error) => { - if (error) { - this._logger.error("On message send error", error); - } - }); // TODO: Handle errors - // TODO: Embed error handling into websocket message flow - return true; - } else { - this._logger.fatal("Websocket connection is not ready -", identifier); - websocketConnection?.close(1011, "Websocket connection is not ready - " + identifier); - return false; - } - } else { - // This can happen when a charging station disconnects in the moment a message is trying to send. - // Retry logic on the message sender might not suffice as charging station might connect to different instance. - this._logger.error("Cannot identify client connection for", identifier); - this._connections.get(identifier)?.close(1011, "Failed to get connection information for " + identifier); - return false; - } - }); - } - - private _onHttpRequest(req: http.IncomingMessage, res: http.ServerResponse) { - if (req.method === "GET" && req.url == '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'healthy' })); - } else { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ message: `Route ${req.method}:${req.url} not found`, error: "Not Found", statusCode: 404 })); - } - } - - /** - * Method to validate websocket upgrade requests and pass them to the socket server. - * - * @param {IncomingMessage} req - The request object. - * @param {Duplex} socket - Websocket duplex stream. - * @param {Buffer} head - Websocket buffer. - * @param {WebSocketServer} wss - Websocket server. - * @param {number} securityProfile - The security profile to use for the websocket connection. See OCPP 2.0.1 Part 2-Specification A.1.3 - */ - private async _upgradeRequest(req: http.IncomingMessage, socket: Duplex, head: Buffer, wss: WebSocketServer, securityProfile: number) { - // Failed mTLS and TLS requests are rejected by the server before getting this far - this._logger.debug("On upgrade request", req.method, req.url, req.headers); - - const identifier = this._getClientIdFromUrl(req.url as string); - if (3 > securityProfile && securityProfile > 0) { - // Validate username/password from authorization header - // - The Authorization header is formatted as follows: - // AUTHORIZATION: Basic :)> - const authHeader = req.headers.authorization; - const [username, password] = Buffer.from(authHeader?.split(' ')[1] || '', 'base64').toString().split(':'); - if (username != identifier || await this._checkPassword(username, password) === false) { - this._logger.warn("Unauthorized", identifier); - this._rejectUpgradeUnauthorized(socket); - return; - } - } - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit('connection', ws, req); - }); - } - - private async _checkPassword(username: string, password: string) { - return (await this._deviceModelRepository.readAllByQuery({ - stationId: username, - component_name: 'SecurityCtrlr', - variable_name: 'BasicAuthPassword', - type: AttributeEnumType.Actual - }).then(r => { - if (r && r[0]) { - // Grabbing value most recently *successfully* set on charger - const hashedPassword = r[0].statuses?.filter(status => status.status !== SetVariableStatusEnumType.Rejected).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).shift(); - if (hashedPassword?.value) { - return bcrypt.compare(password, hashedPassword.value); - } - } - this._logger.warn("Has no password", username); - return false; - })); - } - - /** - * Utility function to reject websocket upgrade requests with 401 status code. - * @param socket - Websocket duplex stream. - */ - private _rejectUpgradeUnauthorized(socket: Duplex) { - socket.write('HTTP/1.1 401 Unauthorized\r\n'); - socket.write('WWW-Authenticate: Basic realm="Access to the WebSocket", charset="UTF-8"\r\n'); - socket.write('\r\n'); - socket.end(); - socket.destroy(); - } - - /** - * Internal method to handle new client connection and ensures supported protocols are used. - * - * @param {Set} protocols - The set of protocols to handle. - * @param {IncomingMessage} req - The request object. - * @param {string} wsServerProtocol - The websocket server protocol. - * @return {boolean|string} - Returns the protocol version if successful, otherwise false. - */ - private _handleProtocols(protocols: Set, req: http.IncomingMessage, wsServerProtocol: string) { - // Only supports configured protocol version - if (protocols.has(wsServerProtocol)) { - - // Get IP address of client - const ip = req.headers["x-forwarded-for"]?.toString().split(",")[0].trim() || req.socket.remoteAddress || "N/A"; - const port = req.socket.remotePort as number; - - // Parse the path to get the client id - const identifier = (req.url as string).split("/")[1]; - const clientConnection = new ClientConnection(identifier, uuidv4(), ip, port); - clientConnection.isAlive = true; - - // Register client - const registered = this._cache.setSync(clientConnection.identifier, JSON.stringify(instanceToPlain(clientConnection)), CacheNamespace.Connections); - if (!registered) { - this._logger.fatal("Failed to register websocket client", identifier, clientConnection); - return false; - } else { - this._logger.debug("Successfully registered websocket client", identifier, clientConnection); - } - - return wsServerProtocol; - } - - // Reject the client trying to connect - return false; - } - - /** - * Internal method to handle the connection event when a WebSocket connection is established. - * This happens after successful protocol exchange with client. - * - * @param {WebSocket} ws - The WebSocket object representing the connection. - * @param {IncomingMessage} req - The request object associated with the connection. - * @return {void} - */ - private _onConnection(ws: WebSocket, req: http.IncomingMessage): void { - - const identifier = this._getClientIdFromUrl(req.url as string); - this._connections.set(identifier, ws); - - // Pause the WebSocket event emitter until broker is established - ws.pause(); - - const clientConnection = this._cache.getSync(identifier, CacheNamespace.Connections, () => ClientConnection); - if (!clientConnection) { - this._logger.fatal("Failed to get client connection", identifier); - ws.close(1011, "Failed to get connection information for " + identifier); - } else { - this._router.registerConnection(clientConnection).then((success) => { - if (success) { - this._logger.info("Successfully connected new charging station.", identifier); - - // Register all websocket events - this._registerWebsocketEvents(identifier, ws); - - // Resume the WebSocket event emitter after events have been subscribed to - ws.resume(); - } else { - this._logger.fatal("Failed to subscribe to message broker for ", identifier); - ws.close(1011, "Failed to subscribe to message broker for " + identifier); - } - }); - } - } - - /** - * Internal method to register event listeners for the WebSocket connection. - * - * @param {string} identifier - The unique identifier for the connection. - * @param {WebSocket} ws - The WebSocket object representing the connection. - * @return {void} This function does not return anything. - */ - private _registerWebsocketEvents(identifier: string, ws: WebSocket): void { - - ws.onerror = (event: ErrorEvent) => { - this._logger.error("Connection error encountered for", identifier, event.error, event.message, event.type); - this._getClientConnection(identifier).then(clientConnection => { - if (clientConnection) { - clientConnection.isAlive = false; - this._cache.set(clientConnection.identifier, JSON.stringify(instanceToPlain(clientConnection)), CacheNamespace.Connections); - } - }); - ws.close(1011, event.message); - }; - - ws.onmessage = (event: MessageEvent) => { - this._getClientConnection(identifier).then(clientConnection => { - if (clientConnection) { - this._onMessage(clientConnection, event.data.toString()); - } - }); - }; - - ws.once("close", () => { - // Unregister client - this._logger.info("Connection closed for", identifier); - this._cache.remove(identifier, CacheNamespace.Connections); - this._connections.delete(identifier); - this._router.handler.unsubscribe(identifier); - }); - - ws.on("pong", () => { - this._logger.debug("Pong received for", identifier); - this._getClientConnection(identifier).then(clientConnection => { - if (clientConnection) { - clientConnection.isAlive = true; - this._cache.set(clientConnection.identifier, JSON.stringify(instanceToPlain(clientConnection)), CacheNamespace.Connections).then(() => { - this._ping(clientConnection.identifier, ws); - }); - } - }); - }); - - this._ping(identifier, ws); - } - - /** - * Internal method to handle the incoming message from the websocket client. - * - * @param {IClientConnection} client - The client connection object. - * @param {string} message - The incoming message from the client. - * @return {void} This function does not return anything. - */ - private _onMessage(client: IClientConnection, message: string): void { - let rpcMessage: any; - let messageTypeId: MessageTypeId | undefined = undefined - let messageId: string = "-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); - messageTypeId = rpcMessage[0]; - messageId = rpcMessage[1]; - } catch (error) { - throw new OcppError(messageId, ErrorCode.FormatViolation, "Invalid message format", { error: error }); - } - switch (messageTypeId) { - case MessageTypeId.Call: - this.onCall(client, rpcMessage as Call); - break; - case MessageTypeId.CallResult: - this.onCallResult(client, rpcMessage as CallResult); - break; - case MessageTypeId.CallError: - this.onCallError(client, rpcMessage as CallError); - break; - default: - throw new OcppError(messageId, ErrorCode.FormatViolation, "Unknown message type id: " + messageTypeId, {}); - } - } catch (error) { - this._logger.error("Error processing message:", message, error); - if (messageTypeId != MessageTypeId.CallResult && messageTypeId != MessageTypeId.CallError) { - if (error instanceof OcppError) { - this.sendCallError(client.identifier, error.asCallError()); - } else { - this.sendCallError(client.identifier, [MessageTypeId.CallError, messageId, ErrorCode.InternalError, "Unable to process message", { error: error }]); - } - } - // TODO: Publish raw payload for error reporting - } - } - - /** - * Internal method to handle the error event for the WebSocket server. - * - * @param {WebSocketServer} wss - The WebSocket server instance. - * @param {Error} error - The error object. - * @return {void} This function does not return anything. - */ - private _onError(wss: WebSocketServer, error: Error): void { - this._logger.error(error); - // TODO: Try to recover the Websocket server - } - - /** - * Internal method to handle the event when the WebSocketServer is closed. - * - * @param {WebSocketServer} wss - The WebSocketServer instance. - * @return {void} This function does not return anything. - */ - private _onClose(wss: WebSocketServer): void { - this._logger.debug("Websocket Server closed"); - // TODO: Try to recover the Websocket server - } - - /** - * Internal method to retrieve the client connection based on the provided identifier. - * - * @param {string} identifier - The identifier of the client connection. - * @return {Promise} A promise that resolves to the client connection if found, otherwise null. - */ - private _getClientConnection(identifier: string): Promise { - return this._cache.get(identifier, CacheNamespace.Connections, () => ClientConnection); - } - - /** - * Internal method to execute a ping operation on a WebSocket connection after a delay of 60 seconds. - * - * @param {string} identifier - The identifier of the client connection. - * @param {WebSocket} ws - The WebSocket connection to ping. - * @return {void} This function does not return anything. - */ - private _ping(identifier: string, ws: WebSocket): void { - setTimeout(() => { - this._getClientConnection(identifier).then(clientConnection => { - if (clientConnection && clientConnection.isAlive) { - this._logger.debug("Pinging client", clientConnection.identifier); - // Set isAlive to false and send ping to client - clientConnection.isAlive = false; - this._cache.set(clientConnection.identifier, JSON.stringify(instanceToPlain(clientConnection)), CacheNamespace.Connections).then(() => { - ws.ping(); - }); - } else { - ws.close(1011, "Client is not alive"); - } - }); - }, this._config.websocket.pingInterval * 1000); - } - /** - * - * @param url Http upgrade request url used by charger - * @returns Charger identifier - */ - private _getClientIdFromUrl(url: string): string { - return url.split("/")[1]; - } - - private async _sendCallIsAllowed(identifier: string, message: Call): Promise { - const status = await this._cache.get(ConfigurationModule.BOOT_STATUS, identifier); - if (status == RegistrationStatusEnumType.Rejected && - // TriggerMessage is the only message allowed to be sent during Rejected BootStatus B03.FR.08 - !(message[2] as CallAction == CallAction.TriggerMessage && (message[3] as TriggerMessageRequest).requestedMessage == MessageTriggerEnumType.BootNotification)) { - return false; - } - return true; - } -} \ No newline at end of file diff --git a/Server/src/util/swagger.ts b/Server/src/util/swagger.ts deleted file mode 100644 index 934d968bb..000000000 --- a/Server/src/util/swagger.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2023 S44, LLC -// Copyright Contributors to the CitrineOS Project -// -// SPDX-License-Identifier: Apache 2.0 - -import fastifySwagger from "@fastify/swagger"; -import fastifySwaggerUi from "@fastify/swagger-ui"; -import { FastifyInstance } from 'fastify'; -import fs from 'fs'; -import { SystemConfig } from '@citrineos/base'; -import { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'; - -/** - * This transformation is necessary because the plugin (@fastify/swagger) does not handle the local #ref objects correctly. - * - * @param {object} swaggerObject - The original Swagger object to be transformed. - * @param {object} openapiObject - The original OpenAPI object to be transformed. - * @return {object} The transformed OpenAPI object. - */ -function OcppTransformObject({ swaggerObject, openapiObject }: { - swaggerObject: Partial; - openapiObject: Partial; -}) { - if (openapiObject.paths && openapiObject.components) { - for (const pathKey in openapiObject.paths) { - const path: OpenAPIV3.PathsObject = openapiObject.paths[pathKey] as OpenAPIV3.PathsObject; - if (path) { - for (const methodKey in path) { - const method: OpenAPIV3.OperationObject = path[methodKey] as OpenAPIV3.OperationObject; - if (method) { - // Set tags based on path key - method.tags = pathKey.split("/").slice(2, -1).map((tag) => tag.charAt(0).toUpperCase() + tag.slice(1)); - - const requestBody: OpenAPIV3.RequestBodyObject = method.requestBody as OpenAPIV3.RequestBodyObject; - if (requestBody) { - for (const mediaTypeObjectKey in requestBody.content) { - const mediaTypeObject: OpenAPIV3.MediaTypeObject = requestBody.content[mediaTypeObjectKey] as OpenAPIV3.MediaTypeObject; - if (mediaTypeObject) { - const schema: any = mediaTypeObject.schema as OpenAPIV3.SchemaObject; - if (schema) { - const refSchemas = schema['definitions']; - delete schema['definitions']; - delete schema['comment']; - for (const key in refSchemas) { - delete refSchemas[key]['javaType']; - delete refSchemas[key]['tsEnumNames']; - delete refSchemas[key]['additionalProperties']; - } - openapiObject.components.schemas = { - ...openapiObject.components.schemas, - ...refSchemas - } - } - } - } - } - } - } - } - } - } - return openapiObject; -}; - -export function initSwagger(systemConfig: SystemConfig, server: FastifyInstance) { - server.register(fastifySwagger, { - openapi: { - info: { - title: 'CitrineOS Central System API', - description: 'Central System API for OCPP 2.0.1 messaging.', - version: '1.0.0' - } - }, - transformObject: OcppTransformObject - }); - - const swaggerUiOptions = { - routePrefix: systemConfig.server.swagger?.path, - exposeRoute: true, - logo: { - type: 'image/png', - content: fs.readFileSync(__dirname + '/../assets/logo.png') - }, - uiConfig: { - filter: true - }, - theme: { - title: "CitrineOS Central System API", - css: [{ - filename: "", - content: ".swagger-ui .topbar { background-color: #fafafa; } .swagger-ui .topbar .download-url-wrapper { display: none; }" - }] - } - }; - - server.register(fastifySwaggerUi, swaggerUiOptions); -} \ No newline at end of file diff --git a/Server/tsconfig.json b/Server/tsconfig.json index 5cebe347b..1aaec51ee 100644 --- a/Server/tsconfig.json +++ b/Server/tsconfig.json @@ -1,21 +1,47 @@ { - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": true, - "outDir": "lib", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, + "extends": "../tsconfig.build.json", "include": [ - "src" + "src/**/*.ts", + "src/**/*.json" ], - "exclude": [ - "node_modules", - "**/__tests__/*" + "compilerOptions": { + "outDir": "./dist/", + "rootDir": "./src", + "composite": true + }, + "references": [ + { + "path": "../00_Base" + }, + { + "path": "../01_Data" + }, + { + "path": "../02_Util" + }, + { + "path": "../03_Modules/Certificates" + }, + { + "path": "../03_Modules/Configuration" + }, + { + "path": "../03_Modules/EVDriver" + }, + { + "path": "../03_Modules/Monitoring" + }, + { + "path": "../03_Modules/OcppRouter" + }, + { + "path": "../03_Modules/Reporting" + }, + { + "path": "../03_Modules/SmartCharging" + }, + { + "path": "../03_Modules/Transactions" + } ] } \ No newline at end of file diff --git a/Server/unix-init-install-all.sh b/Server/unix-init-install-all.sh deleted file mode 100755 index 8a481ff0f..000000000 --- a/Server/unix-init-install-all.sh +++ /dev/null @@ -1,171 +0,0 @@ -#!/bin/bash - -docker network create --driver=bridge citrine-network || echo "Network already exists." - -execute_commands() { - local commands=("$@") - for cmd in "${commands[@]}"; do - echo "Executing: $cmd" - eval "$cmd" - - if [ $? -ne 0 ]; then - echo "Error executing $cmd" - exit 1 - fi - done -} - -# Commands for each module -base_commands=( - "cd ../00_Base" - "rm -rf ./lib" - "rm -f citrineos-base-1.0.0.tgz" - "npm install" - "npm pack" -) - -data_commands=( - "cd ../01_Data" - "rm -rf ./lib" - "rm -f citrineos-data-1.0.0.tgz" - "npm install ../00_Base/citrineos-base-1.0.0.tgz" - "npm install" - "npm pack" -) - -util_commands=( - "cd ../02_Util" - "rm -rf ./lib" - "rm -f citrineos-util-1.0.0.tgz" - "npm install ../00_Base/citrineos-base-1.0.0.tgz" - "npm install" - "npm pack" -) - -certificates_commands=( - "cd ../03_Modules/Certificates" - "rm -rf ./lib" - "rm -f citrineos-certificates-1.0.0.tgz" - "npm install ../../00_Base/citrineos-base-1.0.0.tgz" - "npm install ../../01_Data/citrineos-data-1.0.0.tgz" - "npm install ../../02_Util/citrineos-util-1.0.0.tgz" - "npm install" - "npm pack" -) - -configuration_commands=( - "cd ../03_Modules/Configuration" - "rm -rf ./lib" - "rm -f citrineos-configuration-1.0.0.tgz" - "npm install ../../00_Base/citrineos-base-1.0.0.tgz" - "npm install ../../01_Data/citrineos-data-1.0.0.tgz" - "npm install ../../02_Util/citrineos-util-1.0.0.tgz" - "npm install" - "npm pack" -) - -evdriver_commands=( - "cd ../03_Modules/EVDriver" - "rm -rf ./lib" - "rm -f citrineos-evdriver-1.0.0.tgz" - "npm install ../../00_Base/citrineos-base-1.0.0.tgz" - "npm install ../../01_Data/citrineos-data-1.0.0.tgz" - "npm install ../../02_Util/citrineos-util-1.0.0.tgz" - "npm install" - "npm pack" -) - -monitoring_commands=( - "cd ../03_Modules/Monitoring" - "rm -rf ./lib" - "rm -f citrineos-monitoring-1.0.0.tgz" - "npm install ../../00_Base/citrineos-base-1.0.0.tgz" - "npm install ../../01_Data/citrineos-data-1.0.0.tgz" - "npm install ../../02_Util/citrineos-util-1.0.0.tgz" - "npm install" - "npm pack" -) - -reporting_commands=( - "cd ../03_Modules/Reporting" - "rm -rf ./lib" - "rm -f citrineos-reporting-1.0.0.tgz" - "npm install ../../00_Base/citrineos-base-1.0.0.tgz" - "npm install ../../01_Data/citrineos-data-1.0.0.tgz" - "npm install ../../02_Util/citrineos-util-1.0.0.tgz" - "npm install" - "npm pack" -) - -smartcharging_commands=( - "cd ../03_Modules/SmartCharging" - "rm -rf ./lib" - "rm -f citrineos-smartcharging-1.0.0.tgz" - "npm install ../../00_Base/citrineos-base-1.0.0.tgz" - "npm install ../../01_Data/citrineos-data-1.0.0.tgz" - "npm install ../../02_Util/citrineos-util-1.0.0.tgz" - "npm install" - "npm pack" -) - -transactions_commands=( - "cd ../03_Modules/Transactions" - "rm -rf ./lib" - "rm -f citrineos-transactions-1.0.0.tgz" - "npm install ../../00_Base/citrineos-base-1.0.0.tgz" - "npm install ../../01_Data/citrineos-data-1.0.0.tgz" - "npm install ../../02_Util/citrineos-util-1.0.0.tgz" - "npm install" - "npm pack" -) - -ocpp_server_commands=( - "cd ../Server" - "rm -rf ./lib" - "npm install ../00_Base/citrineos-base-1.0.0.tgz" - "npm install ../01_Data/citrineos-data-1.0.0.tgz" - "npm install ../02_Util/citrineos-util-1.0.0.tgz" - "npm install ../03_Modules/Certificates/citrineos-certificates-1.0.0.tgz" - "npm install ../03_Modules/Configuration/citrineos-configuration-1.0.0.tgz" - "npm install ../03_Modules/EVDriver/citrineos-evdriver-1.0.0.tgz" - "npm install ../03_Modules/Monitoring/citrineos-monitoring-1.0.0.tgz" - "npm install ../03_Modules/Reporting/citrineos-reporting-1.0.0.tgz" - "npm install ../03_Modules/SmartCharging/citrineos-smartcharging-1.0.0.tgz" - "npm install ../03_Modules/Transactions/citrineos-transactions-1.0.0.tgz" - "npm install" -) - -# Execute commands for each module -execute_commands "${base_commands[@]}" -execute_commands "${data_commands[@]}" -execute_commands "${util_commands[@]}" -execute_commands "${certificates_commands[@]}"& -pid_certificates=$! -execute_commands "${configuration_commands[@]}"& -pid_configuration=$! -execute_commands "${evdriver_commands[@]}"& -pid_evdriver=$! -execute_commands "${monitoring_commands[@]}"& -pid_monitoring=$! -execute_commands "${reporting_commands[@]}"& -pid_reporting=$! -execute_commands "${smartcharging_commands[@]}"& -pid_smartcharging=$! -execute_commands "${transactions_commands[@]}"& -pid_transactions=$! - - - -wait $pid_certificates -wait $pid_configuration -wait $pid_evdriver -wait $pid_monitoring -wait $pid_reporting -wait $pid_smartcharging -wait $pid_transactions - -echo "Dependancy Installation Completed! Now initializing the OCPP server..." - -execute_commands "${ocpp_server_commands[@]}" - -echo "All commands executed successfully!" \ No newline at end of file diff --git a/Swarm/directus-env-config.js b/Swarm/directus-env-config.js new file mode 100644 index 000000000..e87d8f7a5 --- /dev/null +++ b/Swarm/directus-env-config.js @@ -0,0 +1,17 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +export default function (env) { + + const config = { + // API Paths + CITRINEOS_SUBSCRIPTION_API_PATH: '/data/ocpprouter/subscription', + DIRECTUS_CHARGING_STATION_UPDATE_STATUS_PATH: '/charging-stations/update-station-status', + // Environment-specific urls + CITRINEOS_URL: "http://citrine:8080", + DIRECTUS_URL: "http://directus:8055" + } + + return config; +} \ No newline at end of file diff --git a/Swarm/directus.Dockerfile b/Swarm/directus.Dockerfile new file mode 100644 index 000000000..3e6ba9e4d --- /dev/null +++ b/Swarm/directus.Dockerfile @@ -0,0 +1,8 @@ +FROM directus/directus:10.10.5 +USER root +COPY tsconfig.build.json /directus +COPY DirectusExtensions/charging-stations-bundle/tsconfig.json /directus/extensions/directus-extension-charging-stations-bundle/tsconfig.json +COPY DirectusExtensions/charging-stations-bundle/package.json /directus/extensions/directus-extension-charging-stations-bundle/package.json +COPY DirectusExtensions/charging-stations-bundle/src /directus/extensions/directus-extension-charging-stations-bundle/src +RUN npm install --prefix /directus/extensions/directus-extension-charging-stations-bundle && npm run build --prefix /directus/extensions/directus-extension-charging-stations-bundle +USER node \ No newline at end of file diff --git a/Swarm/docker-compose.yml b/Swarm/docker-compose.yml index 49a04c4a6..2ba8ffeda 100644 --- a/Swarm/docker-compose.yml +++ b/Swarm/docker-compose.yml @@ -16,7 +16,7 @@ services: timeout: 10s retries: 3 ocpp-db: - image: citrineos/postgres:preseeded + image: citrineos/postgis:v1.1.0 ports: - 5432:5432 volumes: @@ -32,22 +32,31 @@ services: ports: - "6379:6379" healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: [ "CMD", "redis-cli", "ping" ] interval: 10s timeout: 5s retries: 3 directus: - image: directus/directus:latest + build: + context: .. + dockerfile: ./Server/directus.Dockerfile ports: - 8055:8055 volumes: - ./data/directus/uploads:/directus/uploads - - ./data/directus/extensions:/directus/extensions + - ./directus-env-config.cjs:/directus/config.cjs + depends_on: + ocpp-db: + condition: service_healthy environment: + APP_NAME: 'all' KEY: '1234567890' SECRET: '0987654321' ADMIN_EMAIL: 'admin@citrineos.com' ADMIN_PASSWORD: 'CitrineOS!' + CONFIG_PATH: '/directus/config.cjs' + EXTENSIONS_AUTO_RELOAD: 'true' + EXTENSIONS_CACHE_TTL: '1s' DB_CLIENT: 'pg' DB_HOST: ocpp-db DB_PORT: 5432 @@ -55,6 +64,12 @@ services: DB_USER: 'citrine' DB_PASSWORD: 'citrine' WEBSOCKETS_ENABLED: 'true' + healthcheck: + test: wget --no-verbose --tries=1 --spider http://localhost:8055/server/health || exit 1 + start_period: 15s + interval: 15s + timeout: 15s + retries: 3 citrine: build: context: ../ @@ -64,6 +79,8 @@ services: condition: service_started amqp-broker: condition: service_healthy + directus: + condition: service_healthy redis: condition: service_healthy ports: @@ -73,6 +90,8 @@ services: expose: - 8080-8082 environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' APP_NAME: 'general' certificates: build: @@ -90,6 +109,8 @@ services: expose: - 8083 environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' APP_NAME: 'certificates' configuration: build: @@ -110,6 +131,8 @@ services: - ./:/usr/configuration - /usr/configuration/node_modules environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' APP_NAME: 'configuration' evdriver: build: @@ -130,6 +153,8 @@ services: - ./:/usr/evdriver - /usr/evdriver/node_modules environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' APP_NAME: 'evdriver' monitoring: build: @@ -150,6 +175,8 @@ services: - ./:/usr/monitoring - /usr/monitoring/node_modules environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' APP_NAME: 'monitoring' reporting: build: @@ -170,6 +197,8 @@ services: - ./:/usr/reporting - /usr/reporting/node_modules environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' APP_NAME: 'reporting' smartcharging: build: @@ -190,6 +219,8 @@ services: - ./:/usr/smartcharging - /usr/smartcharging/node_modules environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' APP_NAME: 'smartcharging' transactions: build: @@ -210,4 +241,6 @@ services: - ./:/usr/transactions - /usr/transactions/node_modules environment: + CITRINEOS_UTIL_DIRECTUS_USERNAME: 'admin@citrineos.com' + CITRINEOS_UTIL_DIRECTUS_PASSWORD: 'CitrineOS!' APP_NAME: 'transactions' diff --git a/Swarm/docker/Dockerfile b/Swarm/docker/Dockerfile index 757c82915..fb41560f2 100644 --- a/Swarm/docker/Dockerfile +++ b/Swarm/docker/Dockerfile @@ -27,6 +27,7 @@ RUN cd /usr/01_Data && npm pack # Build citrineos-util module FROM base as citrineos-util-builder COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ +COPY --from=citrineos-data-builder /usr/01_Data/*.tgz /usr/01_Data/ COPY /02_Util/package.json /usr/02_Util/ RUN npm install --ignore-scripts=true --prefix /usr/02_Util @@ -35,6 +36,19 @@ COPY /02_Util/src /usr/02_Util/src RUN npm run build --prefix /usr/02_Util RUN cd /usr/02_Util && npm pack +# Build citrineos-ocpprouter module +FROM base as citrineos-ocpprouter-builder +COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ +COPY --from=citrineos-data-builder /usr/01_Data/*.tgz /usr/01_Data/ +COPY --from=citrineos-util-builder /usr/02_Util/*.tgz /usr/02_Util/ +COPY /03_Modules/OcppRouter/package.json /usr/03_Modules/OcppRouter/ +RUN npm install --ignore-scripts=true --prefix /usr/03_Modules/OcppRouter + +COPY /03_Modules/OcppRouter/tsconfig.json /usr/03_Modules/OcppRouter/ +COPY /03_Modules/OcppRouter/src /usr/03_Modules/OcppRouter/src +RUN npm run build --prefix /usr/03_Modules/OcppRouter +RUN cd /usr/03_Modules/OcppRouter && npm pack + # Build citrineos-certificates module FROM base as citrineos-certificates-builder COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ @@ -134,6 +148,7 @@ WORKDIR /usr/server COPY --from=citrineos-base-builder /usr/00_Base/*.tgz /usr/00_Base/ COPY --from=citrineos-data-builder /usr/01_Data/*.tgz /usr/01_Data/ COPY --from=citrineos-util-builder /usr/02_Util/*.tgz /usr/02_Util/ +COPY --from=citrineos-ocpprouter-builder /usr/03_Modules/OcppRouter/*.tgz /usr/03_Modules/OcppRouter/ COPY --from=citrineos-certificates-builder /usr/03_Modules/Certificates/*.tgz /usr/03_Modules/Certificates/ COPY --from=citrineos-configuration-builder /usr/03_Modules/Configuration/*.tgz /usr/03_Modules/Configuration/ COPY --from=citrineos-evdriver-builder /usr/03_Modules/EVDriver/*.tgz /usr/03_Modules/EVDriver/ diff --git a/Swarm/package.json b/Swarm/package.json index 5a4c24a87..a06342f00 100644 --- a/Swarm/package.json +++ b/Swarm/package.json @@ -9,6 +9,7 @@ "install-base": "cd ../00_Base && npm install && npm run build && npm pack && cd ../Swarm && npm install ../00_Base/citrineos-base-1.0.0.tgz", "install-util": "cd ../02_Util && npm install ../00_Base/citrineos-base-1.0.0.tgz && npm run build && npm pack && cd ../Swarm && npm install ../02_Util/citrineos-util-1.0.0.tgz", "install-data": "cd ../01_Data && npm install ../00_Base/citrineos-base-1.0.0.tgz && npm run build && npm pack && cd ../Swarm && npm install ../01_Data/citrineos-data-1.0.0.tgz", + "install-ocpprouter": "cd ../03_Modules/OcppRouter && npm run install-all && npm install && npm run build && npm pack && cd ../../Server && npm install ../03_Modules/OcppRouter/citrineos-ocpprouter-1.0.0.tgz", "install-certificates": "cd ../03_Modules/Certificates && npm run install-all && npm install && npm run build && npm pack && cd ../../Swarm && npm install ../03_Modules/Certificates/citrineos-certificates-1.0.0.tgz", "install-configuration": "cd ../03_Modules/Configuration && npm run install-all && npm install && npm run build && npm pack && cd ../../Swarm && npm install ../03_Modules/Configuration/citrineos-configuration-1.0.0.tgz", "install-evdriver": "cd ../03_Modules/EVDriver && npm run install-all && npm install && npm run build && npm pack && cd ../../Swarm && npm install ../03_Modules/EVDriver/citrineos-evdriver-1.0.0.tgz", @@ -47,12 +48,11 @@ "@citrineos/data": "file:../01_Data/citrineos-data-1.0.0.tgz", "@citrineos/evdriver": "file:../03_Modules/EVDriver/citrineos-evdriver-1.0.0.tgz", "@citrineos/monitoring": "file:../03_Modules/Monitoring/citrineos-monitoring-1.0.0.tgz", + "@citrineos/ocpprouter": "file:../03_Modules/OcppRouter/citrineos-ocpprouter-1.0.0.tgz", "@citrineos/reporting": "file:../03_Modules/Reporting/citrineos-reporting-1.0.0.tgz", "@citrineos/smartcharging": "file:../03_Modules/SmartCharging/citrineos-smartcharging-1.0.0.tgz", "@citrineos/transactions": "file:../03_Modules/Transactions/citrineos-transactions-1.0.0.tgz", "@citrineos/util": "file:../02_Util/citrineos-util-1.0.0.tgz", - "@fastify/swagger": "^8.10.1", - "@fastify/swagger-ui": "^1.9.3", "@fastify/type-provider-json-schema-to-ts": "^2.2.2", "ajv": "^8.12.0", "fastify": "^4.22.2", @@ -62,10 +62,10 @@ "ws": "^8.13.0" }, "engines": { - "node": ">=16" + "node": ">=18" }, "optionalDependencies": { "bufferutil": "^4.0.8", "utf-8-validate": "^6.0.3" } -} \ No newline at end of file +} diff --git a/Swarm/src/config/envs/docker.ts b/Swarm/src/config/envs/docker.ts index 0a80077bf..add9c46d3 100644 --- a/Swarm/src/config/envs/docker.ts +++ b/Swarm/src/config/envs/docker.ts @@ -7,6 +7,10 @@ import { RegistrationStatusEnumType, defineConfig } from "@citrineos/base"; export function createDockerConfig() { return defineConfig({ env: "development", + centralSystem: { + host: "0.0.0.0", + port: 8080 + }, modules: { certificates: { endpointPrefix: "certificates", @@ -19,7 +23,7 @@ export function createDockerConfig() { unknownChargerStatus: RegistrationStatusEnumType.Accepted, getBaseReportOnPending: true, bootWithRejectedVariables: true, - autoAccept: false, + autoAccept: true, endpointPrefix: "configuration", host: "0.0.0.0", port: 8084 @@ -47,7 +51,8 @@ export function createDockerConfig() { transactions: { endpointPrefix: "transactions", host: "0.0.0.0", - port: 8089 + port: 8089, + costUpdatedInterval: 60 }, }, data: { @@ -59,7 +64,7 @@ export function createDockerConfig() { username: "citrine", password: "citrine", storage: "", - sync: true, + sync: false } }, util: { @@ -74,33 +79,40 @@ export function createDockerConfig() { url: "amqp://guest:guest@amqp-broker:5672", exchange: "citrineos", } - } - }, - server: { - logLevel: 2, // debug - host: "0.0.0.0", - port: 8080, + }, swagger: { path: "/docs", + logoPath: "/usr/server/src/assets/logo.png", exposeData: true, exposeMessage: true + }, + directus: { + host: "directus", + port: 8055, + generateFlows: true + }, + networkConnection: { + websocketServers: [{ + id: "0", + securityProfile: 0, + allowUnknownChargingStations: true, + pingInterval: 60, + host: "0.0.0.0", + port: 8081, + protocol: "ocpp2.0.1" + }, { + id: "1", + securityProfile: 1, + allowUnknownChargingStations: false, + pingInterval: 60, + host: "0.0.0.0", + port: 8082, + protocol: "ocpp2.0.1" + }] } - }, - websocket: { - pingInterval: 60, - maxCallLengthSeconds: 5, - maxCachingSeconds: 10 }, - websocketServer: [{ - securityProfile: 0, - host: "0.0.0.0", - port: 8081, - protocol: "ocpp2.0.1" - },{ - securityProfile: 1, - host: "0.0.0.0", - port: 8082, - protocol: "ocpp2.0.1" - }] + logLevel: 2, // debug + maxCallLengthSeconds: 5, + maxCachingSeconds: 10 }); } \ No newline at end of file diff --git a/Swarm/src/config/envs/local.ts b/Swarm/src/config/envs/local.ts index e38a4497e..bedd8b76b 100644 --- a/Swarm/src/config/envs/local.ts +++ b/Swarm/src/config/envs/local.ts @@ -7,6 +7,10 @@ import { RegistrationStatusEnumType, defineConfig } from "@citrineos/base"; export function createLocalConfig() { return defineConfig({ env: "development", + centralSystem: { + host: "0.0.0.0", + port: 8080 + }, modules: { certificates: { endpointPrefix: "/certificates", @@ -19,7 +23,7 @@ export function createLocalConfig() { unknownChargerStatus: RegistrationStatusEnumType.Accepted, getBaseReportOnPending: true, bootWithRejectedVariables: true, - autoAccept: false, + autoAccept: true, endpointPrefix: "/configuration", host: "localhost", port: 8080 @@ -47,7 +51,8 @@ export function createLocalConfig() { transactions: { endpointPrefix: "/transactions", host: "localhost", - port: 8080 + port: 8080, + costUpdatedInterval: 60 }, }, data: { @@ -59,7 +64,7 @@ export function createLocalConfig() { username: "citrine", password: "citrine", storage: "", - sync: true, + sync: false } }, util: { @@ -74,33 +79,38 @@ export function createLocalConfig() { url: "amqp://guest:guest@localhost:5672", exchange: "citrineos", } - } - }, - server: { - logLevel: 2, // debug - host: "0.0.0.0", - port: 8080, + }, swagger: { path: "/docs", + logoPath: "/usr/server/src/assets/logo.png", exposeData: true, exposeMessage: true + }, + directus: { + generateFlows: false + }, + networkConnection: { + websocketServers: [{ + id: "0", + securityProfile: 0, + allowUnknownChargingStations: true, + pingInterval: 60, + host: "0.0.0.0", + port: 8081, + protocol: "ocpp2.0.1" + }, { + id: "1", + securityProfile: 1, + allowUnknownChargingStations: false, + pingInterval: 60, + host: "0.0.0.0", + port: 8082, + protocol: "ocpp2.0.1" + }] } - }, - websocket: { - pingInterval: 60, - maxCallLengthSeconds: 5, - maxCachingSeconds: 10 }, - websocketServer: [{ - securityProfile: 0, - host: "0.0.0.0", - port: 8081, - protocol: "ocpp2.0.1" - },{ - securityProfile: 1, - host: "0.0.0.0", - port: 8082, - protocol: "ocpp2.0.1" - }] + logLevel: 2, // debug + maxCallLengthSeconds: 5, + maxCachingSeconds: 10 }); } \ No newline at end of file diff --git a/Swarm/src/index.ts b/Swarm/src/index.ts index 9ccbf2f94..19a34be6f 100644 --- a/Swarm/src/index.ts +++ b/Swarm/src/index.ts @@ -2,22 +2,25 @@ // Copyright Contributors to the CitrineOS Project // // SPDX-License-Identifier: Apache 2.0 - -import { EventGroup, ICache, ICentralSystem, IMessageHandler, IMessageSender, IModule, IModuleApi, SystemConfig } from '@citrineos/base'; +/* eslint-disable @typescript-eslint/prefer-readonly */ +/* eslint-disable @typescript-eslint/indent */ +/* eslint-disable @typescript-eslint/semi */ +/* eslint-disable @typescript-eslint/quotes */ +/* eslint-disable @typescript-eslint/consistent-type-imports */ +import { EventGroup, IAuthenticator, ICache, IMessageHandler, IMessageSender, IModule, IModuleApi, SystemConfig } from '@citrineos/base'; import { MonitoringModule, MonitoringModuleApi } from '@citrineos/monitoring'; -import { MemoryCache, RabbitMqReceiver, RabbitMqSender, RedisCache } from '@citrineos/util'; +import { Authenticator, DirectusUtil, initSwagger, MemoryCache, RabbitMqReceiver, RabbitMqSender, RedisCache, WebsocketNetworkConnection } from '@citrineos/util'; import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; import Ajv from "ajv"; import addFormats from "ajv-formats" import fastify, { FastifyInstance } from 'fastify'; import { ILogObj, Logger } from 'tslog'; import { systemConfig } from './config'; -import { CentralSystemImpl } from './server/server'; -import { initSwagger } from './util/swagger'; import { ConfigurationModule, ConfigurationModuleApi } from '@citrineos/configuration'; import { TransactionsModule, TransactionsModuleApi } from '@citrineos/transactions'; import { CertificatesModule, CertificatesModuleApi } from '@citrineos/certificates'; import { EVDriverModule, EVDriverModuleApi } from '@citrineos/evdriver'; +import { MessageRouterImpl, AdminApi } from '@citrineos/ocpprouter'; import { ReportingModule, ReportingModuleApi } from '@citrineos/reporting'; import { SmartChargingModule, SmartChargingModuleApi } from '@citrineos/smartcharging'; import { sequelize } from '@citrineos/data'; @@ -28,7 +31,8 @@ class CitrineOSServer { * Fields */ private _config: SystemConfig; - private _centralSystem: ICentralSystem; + private _authenticator: IAuthenticator; + private _networkConnection: WebsocketNetworkConnection; private _logger: Logger; private _server: FastifyInstance; private _cache: ICache; @@ -64,8 +68,10 @@ class CitrineOSServer { // Initialize parent logger this._logger = new Logger({ name: "CitrineOS Logger", - minLevel: systemConfig.server.logLevel, - hideLogPositionForProduction: systemConfig.env === "production" + minLevel: systemConfig.logLevel, + hideLogPositionForProduction: systemConfig.env === "production", + //Disable colors for cloud deployment as some cloude logging environments such as cloudwatch can not interpret colors + stylePrettyLogs: process.env.DEPLOYMENT_TARGET != "cloud" }); // Force sync database @@ -75,16 +81,31 @@ class CitrineOSServer { this._cache = cache || (this._config.util.cache.redis ? new RedisCache({ socket: { host: this._config.util.cache.redis.host, port: this._config.util.cache.redis.port } }) : new MemoryCache()); // Initialize Swagger if enabled - if (this._config.server.swagger) { + if (this._config.util.swagger) { initSwagger(this._config, this._server); } + // Add Directus Message API flow creation if enabled + if (this._config.util.directus?.generateFlows) { + const directusUtil = new DirectusUtil(this._config, this._logger); + this._server.addHook("onRoute", directusUtil.addDirectusMessageApiFlowsFastifyRouteHook.bind(directusUtil)); + this._server.addHook('onReady', async () => { + this._logger.info('Directus actions initialization finished'); + }); + } + // Register AJV for schema validation this._server.setValidatorCompiler(({ schema, method, url, httpPart }) => { return this._ajv.compile(schema); }); - this._centralSystem = new CentralSystemImpl(this._config, this._cache, undefined, undefined, this._logger, ajv); + this._authenticator = new Authenticator(this._cache, new sequelize.LocationRepository(config, this._logger), new sequelize.DeviceModelRepository(config, this._logger), this._logger); + + const router = new MessageRouterImpl(this._config, this._cache, this._createSender(), this._createHandler(), async (identifier: string, message: string) => false, this._logger, this._ajv); + + this._networkConnection = new WebsocketNetworkConnection(this._config, this._cache, this._authenticator, router, this._logger); + + const api = new AdminApi(router, this._server, this._logger) process.on('SIGINT', this.shutdown.bind(this)); process.on('SIGTERM', this.shutdown.bind(this)); @@ -101,8 +122,8 @@ class CitrineOSServer { shutdown() { - // Shut down central system - this._centralSystem.shutdown(); + // Shut down ocpp router + this._networkConnection.shutdown(); // Shutdown server this._server.close(); @@ -116,8 +137,8 @@ class CitrineOSServer { run(): Promise { try { return this._server.listen({ - port: this._config.server.port, - host: this._config.server.host + port: this._config.centralSystem.port, + host: this._config.centralSystem.host }).then(address => { this._logger.info(`Server listening at ${address}`); }).catch(error => { @@ -176,7 +197,7 @@ class ModuleService { // Initialize parent logger this._logger = new Logger({ name: "CitrineOS Logger", - minLevel: systemConfig.server.logLevel, + minLevel: systemConfig.logLevel, hideLogPositionForProduction: systemConfig.env === "production" }); @@ -184,10 +205,16 @@ class ModuleService { this._cache = cache || (this._config.util.cache.redis ? new RedisCache({ socket: { host: this._config.util.cache.redis.host, port: this._config.util.cache.redis.port } }) : new MemoryCache()); // Initialize Swagger if enabled - if (this._config.server.swagger) { + if (this._config.util.swagger) { initSwagger(this._config, this._server); } + // Add Directus Message API flow creation if enabled + if (this._config.util.directus?.generateFlows) { + const directusUtil = new DirectusUtil(this._config, this._logger); + this._server.addHook("onRoute", directusUtil.addDirectusMessageApiFlowsFastifyRouteHook.bind(directusUtil)); + } + // Register AJV for schema validation this._server.setValidatorCompiler(({ schema, method, url, httpPart }) => { return this._ajv.compile(schema); @@ -202,8 +229,8 @@ class ModuleService { this._api = new CertificatesModuleApi(this._module as CertificatesModule, this._server, this._logger); // TODO: take actions to make sure module has correct subscriptions and log proof this._logger.info("Certificates module started..."); - this._host = this._config.modules.certificates.host; - this._port = this._config.modules.certificates.port; + this._host = this._config.modules.certificates.host as string; + this._port = this._config.modules.certificates.port as number; break; } else throw new Error("No config for Certificates module"); case EventGroup.Configuration: @@ -212,8 +239,8 @@ class ModuleService { this._api = new ConfigurationModuleApi(this._module as ConfigurationModule, this._server, this._logger); // TODO: take actions to make sure module has correct subscriptions and log proof this._logger.info("Configuration module started..."); - this._host = this._config.modules.configuration.host; - this._port = this._config.modules.configuration.port; + this._host = this._config.modules.configuration.host as string; + this._port = this._config.modules.configuration.port as number; break; } else throw new Error("No config for Configuration module"); case EventGroup.EVDriver: @@ -222,8 +249,8 @@ class ModuleService { this._api = new EVDriverModuleApi(this._module as EVDriverModule, this._server, this._logger); // TODO: take actions to make sure module has correct subscriptions and log proof this._logger.info("EVDriver module started..."); - this._host = this._config.modules.evdriver.host; - this._port = this._config.modules.evdriver.port; + this._host = this._config.modules.evdriver.host as string; + this._port = this._config.modules.evdriver.port as number; break; } else throw new Error("No config for EVDriver module"); case EventGroup.Monitoring: @@ -232,8 +259,8 @@ class ModuleService { this._api = new MonitoringModuleApi(this._module as MonitoringModule, this._server, this._logger); // TODO: take actions to make sure module has correct subscriptions and log proof this._logger.info("Monitoring module started..."); - this._host = this._config.modules.monitoring.host; - this._port = this._config.modules.monitoring.port; + this._host = this._config.modules.monitoring.host as string; + this._port = this._config.modules.monitoring.port as number; break; } else throw new Error("No config for Monitoring module"); case EventGroup.Reporting: @@ -242,8 +269,8 @@ class ModuleService { this._api = new ReportingModuleApi(this._module as ReportingModule, this._server, this._logger); // TODO: take actions to make sure module has correct subscriptions and log proof this._logger.info("Reporting module started..."); - this._host = this._config.modules.reporting.host; - this._port = this._config.modules.reporting.port; + this._host = this._config.modules.reporting.host as string; + this._port = this._config.modules.reporting.port as number; break; } else throw new Error("No config for Reporting module"); case EventGroup.SmartCharging: @@ -252,8 +279,8 @@ class ModuleService { this._api = new SmartChargingModuleApi(this._module as SmartChargingModule, this._server, this._logger); // TODO: take actions to make sure module has correct subscriptions and log proof this._logger.info("SmartCharging module started..."); - this._host = this._config.modules.smartcharging.host; - this._port = this._config.modules.smartcharging.port; + this._host = this._config.modules.smartcharging.host as string; + this._port = this._config.modules.smartcharging.port as number; break; } else throw new Error("No config for SmartCharging module"); case EventGroup.Transactions: @@ -262,8 +289,8 @@ class ModuleService { this._api = new TransactionsModuleApi(this._module as TransactionsModule, this._server, this._logger); // TODO: take actions to make sure module has correct subscriptions and log proof this._logger.info("Transactions module started..."); - this._host = this._config.modules.transactions.host; - this._port = this._config.modules.transactions.port; + this._host = this._config.modules.transactions.host as string; + this._port = this._config.modules.transactions.port as number; break; } else throw new Error("No config for Transactions module"); default: diff --git a/Swarm/src/server/connection.ts b/Swarm/src/server/connection.ts deleted file mode 100644 index 2e7027744..000000000 --- a/Swarm/src/server/connection.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2023 S44, LLC -// Copyright Contributors to the CitrineOS Project -// -// SPDX-License-Identifier: Apache 2.0 - -import { IClientConnection } from "@citrineos/base"; - -/** - * Implementation of the client connection - */ -export class ClientConnectionImpl implements IClientConnection { - - /** - * Fields - */ - - private _identifier: string; - private _sessionIndex: string; - private _ip: string; - private _port: number; - private _isAlive: boolean; - - /** - * Constructor - */ - - constructor(identifier: string, sessionIndex: string, ip: string, port: number) { - this._identifier = identifier; - this._sessionIndex = sessionIndex; - this._ip = ip; - this._port = port; - this._isAlive = false; - } - - /** - * Properties - */ - - get identifier(): string { - return this._identifier; - } - - get sessionIndex(): string { - return this._sessionIndex; - } - - get ip(): string { - return this._ip; - } - - get port(): number { - return this._port; - } - - get isAlive(): boolean { - return this._isAlive; - } - - set isAlive(value: boolean) { - this._isAlive = value; - } - - get connectionUrl(): string { - return `ws://${this._ip}:${this._port}/${this._identifier}`; - } -} \ No newline at end of file diff --git a/Swarm/src/server/router.ts b/Swarm/src/server/router.ts deleted file mode 100644 index 69c51fbb7..000000000 --- a/Swarm/src/server/router.ts +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright (c) 2023 S44, LLC -// Copyright Contributors to the CitrineOS Project -// -// SPDX-License-Identifier: Apache 2.0 - -import { Call, CallAction, CallError, CallResult, EventGroup, ICache, ICentralSystem, IClientConnection, IMessage, IMessageConfirmation, IMessageContext, IMessageHandler, IMessageRouter, IMessageSender, MessageOrigin, MessageState, MessageTypeId, OcppError, OcppRequest, OcppResponse, SystemConfig } from "@citrineos/base"; -import { RabbitMqReceiver } from "@citrineos/util"; -import { ILogObj, Logger } from "tslog"; - -const logger = new Logger({ name: "OCPPMessageRouter" }); - -/** - * Implementation of a message handler utilizing {@link RabbitMqReceiver} as the underlying transport. - */ -export class CentralSystemMessageHandler extends RabbitMqReceiver { - - /** - * Fields - */ - - private _centralSystem: ICentralSystem; - - /** - * Constructor - * - * @param centralSystem Central system implementation to use - */ - - constructor(systemConfig: SystemConfig, centralSystem: ICentralSystem, logger?: Logger) { - super(systemConfig, logger); - this._centralSystem = centralSystem; - } - - /** - * Methods - */ - - async handle(message: IMessage, context?: IMessageContext): Promise { - - logger.debug("Received message:", message); - - if (message.state === MessageState.Response) { - if (message.payload instanceof OcppError) { - let callError = (message.payload as OcppError).asCallError(); - await this._centralSystem.sendCallError(message.context.stationId, callError); - } else { - let callResult = [MessageTypeId.CallResult, message.context.correlationId, message.payload] as CallResult; - await this._centralSystem.sendCallResult(message.context.stationId, callResult); - } - } else if (message.state === MessageState.Request) { - let call = [MessageTypeId.Call, message.context.correlationId, message.action, message.payload] as Call; - await this._centralSystem.sendCall(message.context.stationId, call); - } - } -} - -export class OcppMessageRouter implements IMessageRouter { - - public readonly CALLBACK_URL_CACHE_PREFIX: string = "CALLBACK_URL_"; - - private _cache: ICache; - private _sender: IMessageSender; - private _handler: IMessageHandler; - - constructor(cache: ICache, sender: IMessageSender, handler: IMessageHandler) { - this._cache = cache; - this._sender = sender; - this._handler = handler; - } - - async registerConnection(client: IClientConnection): Promise { - const requestSubscription = await this.handler.subscribe(client.identifier, undefined, { - stationId: client.identifier, - state: MessageState.Request.toString(), - origin: MessageOrigin.CentralSystem.toString() - }); - - const responseSubscription = await this.handler.subscribe(client.identifier, undefined, { - stationId: client.identifier, - state: MessageState.Response.toString(), - origin: MessageOrigin.ChargingStation.toString() - }); - - return requestSubscription && responseSubscription; - } - - routeCall(client: IClientConnection, message: Call): Promise { - let messageId = message[1]; - let action = message[2] as CallAction; - let payload = message[3] as OcppRequest; - - // TODO: Add tenantId to context - let context: IMessageContext = { correlationId: messageId, stationId: client.identifier, tenantId: '' }; - - // TODO: Use base util builder instead - const _message: IMessage = { - origin: MessageOrigin.ChargingStation, - eventGroup: EventGroup.General, // TODO: Change to appropriate event group - action, - state: MessageState.Request, - context, - payload - }; - - return this._sender.send(_message); - } - - routeCallResult(client: IClientConnection, message: CallResult, action: CallAction): Promise { - let messageId = message[1]; - let payload = message[2] as OcppResponse; - - // TODO: Add tenantId to context - let context: IMessageContext = { correlationId: messageId, stationId: client.identifier, tenantId: '' }; - - const _message: IMessage = { - origin: MessageOrigin.CentralSystem, - eventGroup: EventGroup.General, // TODO: Change to appropriate event group based on cache value to allow module to receive responses for requests it sent - action, - state: MessageState.Response, - context, - payload - }; - - return this._sender.send(_message); - } - - routeCallError(client: IClientConnection, message: CallError, action: CallAction): Promise { - let messageId = message[1]; - let payload = new OcppError(messageId, message[2], message[3], message[4]); - - // TODO: Add tenantId to context - let context: IMessageContext = { correlationId: messageId, stationId: client.identifier, tenantId: '' }; - - const _message: IMessage = { - origin: MessageOrigin.CentralSystem, - eventGroup: EventGroup.General, - action, - state: MessageState.Response, - context, - payload - }; - - // Fulfill callback for api, if needed - this.handleMessageApiCallback(_message); - - // No error routing currently done - throw new Error('Method not implemented.'); - } - - async handleMessageApiCallback(message: IMessage): Promise { - const url: string | null = await this._cache.get(message.context.correlationId, this.CALLBACK_URL_CACHE_PREFIX + message.context.stationId); - if (url) { - await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(message.payload) - }); - } - } - - get sender(): IMessageSender { - return this._sender; - } - - get handler(): IMessageHandler { - return this._handler; - } -} \ No newline at end of file diff --git a/Swarm/src/server/server.ts b/Swarm/src/server/server.ts deleted file mode 100644 index 2ce5f8b9d..000000000 --- a/Swarm/src/server/server.ts +++ /dev/null @@ -1,734 +0,0 @@ -// Copyright (c) 2023 S44, LLC -// Copyright Contributors to the CitrineOS Project -// -// SPDX-License-Identifier: Apache 2.0 - -import { AbstractCentralSystem, AttributeEnumType, CacheNamespace, Call, CallAction, CallError, CallResult, ClientConnection, ErrorCode, EventGroup, ICache, ICentralSystem, IClientConnection, IMessageRouter, IMessageSender, MessageTriggerEnumType, MessageTypeId, Namespace, OcppError, RegistrationStatusEnumType, RetryMessageError, SetVariableStatusEnumType, SystemConfig, TriggerMessageRequest } from "@citrineos/base"; -import { RabbitMqSender } from "@citrineos/util"; -import Ajv from "ajv"; -import * as bcrypt from "bcrypt"; -import { instanceToPlain } from "class-transformer"; -import * as https from "https"; -import * as http from "http"; -import fs from "fs"; -import { ILogObj, Logger } from "tslog"; -import { v4 as uuidv4 } from "uuid"; -import { ErrorEvent, MessageEvent, WebSocket, WebSocketServer } from "ws"; -import { CentralSystemMessageHandler, OcppMessageRouter } from "./router"; -import { DefaultSequelizeInstance, DeviceModelRepository } from "@citrineos/data/lib/layers/sequelize"; -import { Duplex } from "stream"; -import { ConfigurationModule } from "@citrineos/configuration"; - -/** - * Implementation of the central system - */ -export class CentralSystemImpl extends AbstractCentralSystem implements ICentralSystem { - - /** - * Fields - */ - - protected _cache: ICache; - private _router: IMessageRouter; - private _connections: Map = new Map(); - private _httpServers: (http.Server | https.Server)[]; - private _deviceModelRepository: DeviceModelRepository; - - /** - * Constructor for the class. - * - * @param {SystemConfig} config - the system configuration - * @param {ICache} cache - the cache object - * @param {IMessageSender} [sender] - the message sender (optional) - * @param {IMessageHandler} [handler] - the message handler (optional) - * @param {Logger} [logger] - the logger object (optional) - * @param {Ajv} [ajv] - the Ajv object (optional) - */ - constructor( - config: SystemConfig, - cache: ICache, - sender?: IMessageSender, - handler?: CentralSystemMessageHandler, - logger?: Logger, - ajv?: Ajv, - deviceModelRepository?: DeviceModelRepository) { - super(config, logger, cache, ajv); - - // Initialize router before socket server to avoid race condition - this._router = new OcppMessageRouter(cache, - sender || new RabbitMqSender(config, logger), - handler || new CentralSystemMessageHandler(config, this, logger)); - - this._cache = cache; - - this._deviceModelRepository = deviceModelRepository || new DeviceModelRepository(this._config, this._logger); - - this._httpServers = []; - this._config.websocketServer.forEach(wsServer => { - let _httpServer; - switch (wsServer.securityProfile) { - case 3: // mTLS - _httpServer = https.createServer({ - key: fs.readFileSync(this._config.websocketSecurity?.tlsKeysFilepath as string), - cert: fs.readFileSync(this._config.websocketSecurity?.tlsCertificateChainFilepath as string), - ca: fs.readFileSync(this._config.websocketSecurity?.mtlsCertificateAuthorityRootsFilepath as string), - requestCert: true, - rejectUnauthorized: true - }, this._onHttpRequest.bind(this)); - break; - case 2: // TLS - _httpServer = https.createServer({ - key: fs.readFileSync(this._config.websocketSecurity?.tlsKeysFilepath as string), - cert: fs.readFileSync(this._config.websocketSecurity?.tlsCertificateChainFilepath as string) - }, this._onHttpRequest.bind(this)); - break; - case 1: - case 0: - default: // No TLS - _httpServer = http.createServer(this._onHttpRequest.bind(this)); - break; - } - - let _socketServer = new WebSocketServer({ - noServer: true, - handleProtocols: (protocols, req) => this._handleProtocols(protocols, req, wsServer.protocol), - clientTracking: false - }); - - _socketServer.on('connection', (ws: WebSocket, req: http.IncomingMessage) => this._onConnection(ws, req)); - _socketServer.on('error', (wss: WebSocketServer, error: Error) => this._onError(wss, error)); - _socketServer.on('close', (wss: WebSocketServer) => this._onClose(wss)); - - _httpServer.on('upgrade', (request, socket, head) => - this._upgradeRequest(request, socket, head, _socketServer, wsServer.securityProfile)); - _httpServer.on('error', (error) => _socketServer.emit('error', error)); - // socketServer.close() will not do anything; use httpServer.close() - _httpServer.on('close', () => _socketServer.emit('close')); - const protocol = wsServer.securityProfile > 1 ? 'wss' : 'ws'; - _httpServer.listen(wsServer.port, wsServer.host, () => { - this._logger.info(`WebsocketServer running on ${protocol}://${wsServer.host}:${wsServer.port}/`) - }); - this._httpServers.push(_httpServer); - }); - } - - /** - * Interface implementation - */ - - shutdown(): void { - this._router.sender.shutdown(); - this._router.handler.shutdown(); - this._httpServers.forEach(server => server.close()); - } - - /** - * Sets the system configuration. - * - * @param {SystemConfig} config - The new configuration to set. - */ - set config(config: SystemConfig) { - this._config = config; - // Update all necessary settings for hot reload - this._logger.info(`Updating system configuration for central system...`); - this._logger.settings.minLevel = this._config.server.logLevel; - - for (const module of Object.values(EventGroup)) { - if (module != EventGroup.General) { - const moduleConfig = this._config.modules[module]; - if (moduleConfig) { - const host = moduleConfig.host; - const port = moduleConfig.port; - const path = `/data/${module}/${Namespace.SystemConfig.charAt(0).toLowerCase() + Namespace.SystemConfig.slice(1)}`; - const url = `http://${host}:${port}${path}`; - fetch(url, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(this._config) - }).catch(error => { - this._logger.error("Failed sending config update: ", error); - }); - } - } - } - } - - get config(): SystemConfig { - return this._config; - } - - /** - * Handles an incoming Call message from a client connection. - * - * @param {IClientConnection} connection - The client connection object. - * @param {Call} message - The Call message received. - * @return {void} - */ - onCall(connection: IClientConnection, message: Call): void { - const messageId = message[1]; - const action = message[2] as CallAction; - const payload = message[3]; - - this._onCallIsAllowed(action, connection.identifier) - .then((isAllowed: boolean) => { - if (!isAllowed) { - throw new OcppError(messageId, ErrorCode.SecurityError, `Action ${action} not allowed`); - } else { - // Run schema validation for incoming Call message - return this._validateCall(connection.identifier, message); - } - }).then(({ isValid, errors }) => { - if (!isValid || errors) { - throw new OcppError(messageId, ErrorCode.FormatViolation, "Invalid message format", { errors: errors }); - } - // Ensure only one call is processed at a time - return this._cache.setIfNotExist(connection.identifier, `${action}:${messageId}`, CacheNamespace.Transactions, this._config.websocket.maxCallLengthSeconds); - }).catch(error => { - if (error instanceof OcppError) { - this.sendCallError(connection.identifier, error.asCallError()); - } - }).then(successfullySet => { - if (!successfullySet) { - throw new OcppError(messageId, ErrorCode.RpcFrameworkError, "Call already in progress", {}); - } - // Route call - return this._router.routeCall(connection, message); - }).then(confirmation => { - if (!confirmation.success) { - throw new OcppError(messageId, ErrorCode.InternalError, 'Call failed', { details: confirmation.payload }); - } - }).catch(error => { - if (error instanceof OcppError) { - this.sendCallError(connection.identifier, error.asCallError()); - this._cache.remove(connection.identifier, CacheNamespace.Transactions); - } - }); - } - - /** - * Handles a CallResult made by the client. - * - * @param {IClientConnection} connection - The client connection that made the call. - * @param {CallResult} message - The OCPP CallResult message. - * @return {void} - */ - onCallResult(connection: IClientConnection, message: CallResult): void { - const messageId = message[1]; - const payload = message[2]; - - this._logger.debug("Process CallResult", connection.identifier, messageId, payload); - - this._cache.get(connection.identifier, CacheNamespace.Transactions) - .then(cachedActionMessageId => { - this._cache.remove(connection.identifier, CacheNamespace.Transactions); // Always remove pending call transaction - if (!cachedActionMessageId) { - throw new OcppError(messageId, ErrorCode.InternalError, "MessageId not found, call may have timed out", { "maxCallLengthSeconds": this._config.websocket.maxCallLengthSeconds }); - } - const [actionString, cachedMessageId] = cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId - if (messageId !== cachedMessageId) { - throw new OcppError(messageId, ErrorCode.InternalError, "MessageId doesn't match", { "expectedMessageId": cachedMessageId }); - } - const action: CallAction = CallAction[actionString as keyof typeof CallAction]; // Parse CallAction - return { action, ...this._validateCallResult(connection.identifier, action, message) }; // Run schema validation for incoming CallResult message - }).then(({ action, isValid, errors }) => { - if (!isValid || errors) { - throw new OcppError(messageId, ErrorCode.FormatViolation, "Invalid message format", { errors: errors }); - } - // Route call result - return this._router.routeCallResult(connection, message, action); - }).then(confirmation => { - if (!confirmation.success) { - throw new OcppError(messageId, ErrorCode.InternalError, 'CallResult failed', { details: confirmation.payload }); - } - }).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 result: ", error); - }); - } - - /** - * Handles the CallError that may have occured during a Call exchange. - * - * @param {IClientConnection} connection - The client connection object. - * @param {CallError} message - The error message. - * @return {void} This function doesn't return anything. - */ - onCallError(connection: IClientConnection, message: CallError): void { - - const messageId = message[1]; - - this._logger.debug("Process CallError", connection.identifier, message); - - this._cache.get(connection.identifier, CacheNamespace.Transactions) - .then(cachedActionMessageId => { - this._cache.remove(connection.identifier, CacheNamespace.Transactions); // Always remove pending call transaction - if (!cachedActionMessageId) { - throw new OcppError(messageId, ErrorCode.InternalError, "MessageId not found, call may have timed out", { "maxCallLengthSeconds": this._config.websocket.maxCallLengthSeconds }); - } - const [actionString, cachedMessageId] = cachedActionMessageId.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId - if (messageId !== cachedMessageId) { - throw new OcppError(messageId, ErrorCode.InternalError, "MessageId doesn't match", { "expectedMessageId": cachedMessageId }); - } - const action: CallAction = CallAction[actionString as keyof typeof CallAction]; // Parse CallAction - return this._router.routeCallError(connection, message, action); - }).then(confirmation => { - if (!confirmation.success) { - throw new OcppError(messageId, ErrorCode.InternalError, 'CallError failed', { details: confirmation.payload }); - } - }).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); - }); - } - - /** - * 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} A promise that resolves to a boolean indicating if the call was sent successfully. - */ - async sendCall(identifier: string, message: Call): Promise { - const messageId = message[1]; - const action = message[2] as CallAction; - if (await this._sendCallIsAllowed(identifier, message)) { - if (await this._cache.setIfNotExist(identifier, `${action}:${messageId}`, - CacheNamespace.Transactions, this._config.websocket.maxCallLengthSeconds)) { - // Intentionally removing NULL values from object for OCPP conformity - const rawMessage = JSON.stringify(message, (k, v) => v ?? undefined); - return this._sendMessage(identifier, rawMessage); - } else { - this._logger.info("Call already in progress, throwing retry exception", identifier, message); - throw new RetryMessageError("Call already in progress"); - } - } else { - this._logger.info("RegistrationStatus Rejected, unable to send", identifier, message); - return 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} A promise that resolves to true if the call result was sent successfully, or false otherwise. - */ - async sendCallResult(identifier: string, message: CallResult): Promise { - const messageId = message[1]; - const cachedActionMessageId = await this._cache.get(identifier, CacheNamespace.Transactions); - if (!cachedActionMessageId) { - this._logger.error("Failed to send callResult due to missing message id", identifier, message); - return false; - } - let [cachedAction, cachedMessageId] = cachedActionMessageId?.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId - if (cachedMessageId === messageId) { - // Intentionally removing NULL values from object for OCPP conformity - const rawMessage = JSON.stringify(message, (k, v) => v ?? undefined); - return Promise.all([ - this._sendMessage(identifier, rawMessage), - this._cache.remove(identifier, CacheNamespace.Transactions) - ]).then(successes => successes.every(Boolean)); - } else { - this._logger.error("Failed to send callResult due to mismatch in message id", identifier, cachedActionMessageId, message); - return 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} - A promise that resolves to true if the message was sent successfully. - */ - async sendCallError(identifier: string, message: CallError): Promise { - const messageId = message[1]; - const cachedActionMessageId = await this._cache.get(identifier, CacheNamespace.Transactions); - if (!cachedActionMessageId) { - this._logger.error("Failed to send callError due to missing message id", identifier, message); - return false; - } - let [cachedAction, cachedMessageId] = cachedActionMessageId?.split(/:(.*)/); // Returns all characters after first ':' in case ':' is used in messageId - if (cachedMessageId === messageId) { - // Intentionally removing NULL values from object for OCPP conformity - const rawMessage = JSON.stringify(message, (k, v) => v ?? undefined); - return Promise.all([ - this._sendMessage(identifier, rawMessage), - this._cache.remove(identifier, CacheNamespace.Transactions) - ]).then(successes => successes.every(Boolean)); - } else { - this._logger.error("Failed to send callError due to mismatch in message id", identifier, cachedActionMessageId, message); - return false; - } - } - - /** - * Methods - */ - - /** - * 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} A promise that resolves to a boolean indicating if the action and identifier are allowed. - */ - private _onCallIsAllowed(action: CallAction, identifier: string): Promise { - return this._cache.exists(action, identifier).then(blacklisted => !blacklisted); - } - - /** - * Internal method to send a message to the charging station specified by the identifier. - * - * @param {string} identifier - The identifier of the client. - * @param {string} message - The message to send. - * @return {void} This function does not return anything. - */ - private _sendMessage(identifier: string, message: string): Promise { - return this._getClientConnection(identifier).then(clientConnection => { - if (clientConnection) { - const websocketConnection = this._connections.get(identifier); - if (websocketConnection && websocketConnection.readyState === WebSocket.OPEN) { - websocketConnection.send(message, (error) => { - if (error) { - this._logger.error("On message send error", error); - } - }); // TODO: Handle errors - // TODO: Embed error handling into websocket message flow - return true; - } else { - this._logger.fatal("Websocket connection is not ready -", identifier); - websocketConnection?.close(1011, "Websocket connection is not ready - " + identifier); - return false; - } - } else { - // This can happen when a charging station disconnects in the moment a message is trying to send. - // Retry logic on the message sender might not suffice as charging station might connect to different instance. - this._logger.error("Cannot identify client connection for", identifier); - this._connections.get(identifier)?.close(1011, "Failed to get connection information for " + identifier); - return false; - } - }); - } - - private _onHttpRequest(req: http.IncomingMessage, res: http.ServerResponse) { - if (req.method === "GET" && req.url == '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'healthy' })); - } else { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ message: `Route ${req.method}:${req.url} not found`, error: "Not Found", statusCode: 404 })); - } - } - - /** - * Method to validate websocket upgrade requests and pass them to the socket server. - * - * @param {IncomingMessage} req - The request object. - * @param {Duplex} socket - Websocket duplex stream. - * @param {Buffer} head - Websocket buffer. - * @param {WebSocketServer} wss - Websocket server. - * @param {number} securityProfile - The security profile to use for the websocket connection. See OCPP 2.0.1 Part 2-Specification A.1.3 - */ - private async _upgradeRequest(req: http.IncomingMessage, socket: Duplex, head: Buffer, wss: WebSocketServer, securityProfile: number) { - // Failed mTLS and TLS requests are rejected by the server before getting this far - this._logger.debug("On upgrade request", req.method, req.url, req.headers); - - const identifier = this._getClientIdFromUrl(req.url as string); - if (3 > securityProfile && securityProfile > 0) { - // Validate username/password from authorization header - // - The Authorization header is formatted as follows: - // AUTHORIZATION: Basic :)> - const authHeader = req.headers.authorization; - const [username, password] = Buffer.from(authHeader?.split(' ')[1] || '', 'base64').toString().split(':'); - if (username != identifier || await this._checkPassword(username, password) === false) { - this._logger.warn("Unauthorized", identifier); - this._rejectUpgradeUnauthorized(socket); - return; - } - } - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit('connection', ws, req); - }); - } - - private async _checkPassword(username: string, password: string) { - return (await this._deviceModelRepository.readAllByQuery({ - stationId: username, - component_name: 'SecurityCtrlr', - variable_name: 'BasicAuthPassword', - type: AttributeEnumType.Actual - }).then(r => { - if (r && r[0]) { - // Grabbing value most recently *successfully* set on charger - const hashedPassword = r[0].statuses?.filter(status => status.status !== SetVariableStatusEnumType.Rejected).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).shift(); - if (hashedPassword?.value) { - return bcrypt.compare(password, hashedPassword.value); - } - } - this._logger.warn("Has no password", username); - return false; - })); - } - - /** - * Utility function to reject websocket upgrade requests with 401 status code. - * @param socket - Websocket duplex stream. - */ - private _rejectUpgradeUnauthorized(socket: Duplex) { - socket.write('HTTP/1.1 401 Unauthorized\r\n'); - socket.write('WWW-Authenticate: Basic realm="Access to the WebSocket", charset="UTF-8"\r\n'); - socket.write('\r\n'); - socket.end(); - socket.destroy(); - } - - /** - * Internal method to handle new client connection and ensures supported protocols are used. - * - * @param {Set} protocols - The set of protocols to handle. - * @param {IncomingMessage} req - The request object. - * @param {string} wsServerProtocol - The websocket server protocol. - * @return {boolean|string} - Returns the protocol version if successful, otherwise false. - */ - private _handleProtocols(protocols: Set, req: http.IncomingMessage, wsServerProtocol: string) { - // Only supports configured protocol version - if (protocols.has(wsServerProtocol)) { - - // Get IP address of client - const ip = req.headers["x-forwarded-for"]?.toString().split(",")[0].trim() || req.socket.remoteAddress || "N/A"; - const port = req.socket.remotePort as number; - - // Parse the path to get the client id - const identifier = (req.url as string).split("/")[1]; - const clientConnection = new ClientConnection(identifier, uuidv4(), ip, port); - clientConnection.isAlive = true; - - // Register client - const registered = this._cache.setSync(clientConnection.identifier, JSON.stringify(instanceToPlain(clientConnection)), CacheNamespace.Connections); - if (!registered) { - this._logger.fatal("Failed to register websocket client", identifier, clientConnection); - return false; - } else { - this._logger.debug("Successfully registered websocket client", identifier, clientConnection); - } - - return wsServerProtocol; - } - - // Reject the client trying to connect - return false; - } - - /** - * Internal method to handle the connection event when a WebSocket connection is established. - * This happens after successful protocol exchange with client. - * - * @param {WebSocket} ws - The WebSocket object representing the connection. - * @param {IncomingMessage} req - The request object associated with the connection. - * @return {void} - */ - private _onConnection(ws: WebSocket, req: http.IncomingMessage): void { - - const identifier = this._getClientIdFromUrl(req.url as string); - this._connections.set(identifier, ws); - - // Pause the WebSocket event emitter until broker is established - ws.pause(); - - const clientConnection = this._cache.getSync(identifier, CacheNamespace.Connections, () => ClientConnection); - if (!clientConnection) { - this._logger.fatal("Failed to get client connection", identifier); - ws.close(1011, "Failed to get connection information for " + identifier); - } else { - this._router.registerConnection(clientConnection).then((success) => { - if (success) { - this._logger.info("Successfully connected new charging station.", identifier); - - // Register all websocket events - this._registerWebsocketEvents(identifier, ws); - - // Resume the WebSocket event emitter after events have been subscribed to - ws.resume(); - } else { - this._logger.fatal("Failed to subscribe to message broker for ", identifier); - ws.close(1011, "Failed to subscribe to message broker for " + identifier); - } - }); - } - } - - /** - * Internal method to register event listeners for the WebSocket connection. - * - * @param {string} identifier - The unique identifier for the connection. - * @param {WebSocket} ws - The WebSocket object representing the connection. - * @return {void} This function does not return anything. - */ - private _registerWebsocketEvents(identifier: string, ws: WebSocket): void { - - ws.onerror = (event: ErrorEvent) => { - this._logger.error("Connection error encountered for", identifier, event.error, event.message, event.type); - this._getClientConnection(identifier).then(clientConnection => { - if (clientConnection) { - clientConnection.isAlive = false; - this._cache.set(clientConnection.identifier, JSON.stringify(instanceToPlain(clientConnection)), CacheNamespace.Connections); - } - }); - ws.close(1011, event.message); - }; - - ws.onmessage = (event: MessageEvent) => { - this._getClientConnection(identifier).then(clientConnection => { - if (clientConnection) { - this._onMessage(clientConnection, event.data.toString()); - } - }); - }; - - ws.once("close", () => { - // Unregister client - this._logger.info("Connection closed for", identifier); - this._cache.remove(identifier, CacheNamespace.Connections); - this._connections.delete(identifier); - this._router.handler.unsubscribe(identifier); - }); - - ws.on("pong", () => { - this._logger.debug("Pong received for", identifier); - this._getClientConnection(identifier).then(clientConnection => { - if (clientConnection) { - clientConnection.isAlive = true; - this._cache.set(clientConnection.identifier, JSON.stringify(instanceToPlain(clientConnection)), CacheNamespace.Connections).then(() => { - this._ping(clientConnection.identifier, ws); - }); - } - }); - }); - - this._ping(identifier, ws); - } - - /** - * Internal method to handle the incoming message from the websocket client. - * - * @param {IClientConnection} client - The client connection object. - * @param {string} message - The incoming message from the client. - * @return {void} This function does not return anything. - */ - private _onMessage(client: IClientConnection, message: string): void { - let rpcMessage: any; - let messageTypeId: MessageTypeId | undefined = undefined - let messageId: string = "-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); - messageTypeId = rpcMessage[0]; - messageId = rpcMessage[1]; - } catch (error) { - throw new OcppError(messageId, ErrorCode.FormatViolation, "Invalid message format", { error: error }); - } - switch (messageTypeId) { - case MessageTypeId.Call: - this.onCall(client, rpcMessage as Call); - break; - case MessageTypeId.CallResult: - this.onCallResult(client, rpcMessage as CallResult); - break; - case MessageTypeId.CallError: - this.onCallError(client, rpcMessage as CallError); - break; - default: - throw new OcppError(messageId, ErrorCode.FormatViolation, "Unknown message type id: " + messageTypeId, {}); - } - } catch (error) { - this._logger.error("Error processing message:", message, error); - if (messageTypeId != MessageTypeId.CallResult && messageTypeId != MessageTypeId.CallError) { - if (error instanceof OcppError) { - this.sendCallError(client.identifier, error.asCallError()); - } else { - this.sendCallError(client.identifier, [MessageTypeId.CallError, messageId, ErrorCode.InternalError, "Unable to process message", { error: error }]); - } - } - // TODO: Publish raw payload for error reporting - } - } - - /** - * Internal method to handle the error event for the WebSocket server. - * - * @param {WebSocketServer} wss - The WebSocket server instance. - * @param {Error} error - The error object. - * @return {void} This function does not return anything. - */ - private _onError(wss: WebSocketServer, error: Error): void { - this._logger.error(error); - // TODO: Try to recover the Websocket server - } - - /** - * Internal method to handle the event when the WebSocketServer is closed. - * - * @param {WebSocketServer} wss - The WebSocketServer instance. - * @return {void} This function does not return anything. - */ - private _onClose(wss: WebSocketServer): void { - this._logger.debug("Websocket Server closed"); - // TODO: Try to recover the Websocket server - } - - /** - * Internal method to retrieve the client connection based on the provided identifier. - * - * @param {string} identifier - The identifier of the client connection. - * @return {Promise} A promise that resolves to the client connection if found, otherwise null. - */ - private _getClientConnection(identifier: string): Promise { - return this._cache.get(identifier, CacheNamespace.Connections, () => ClientConnection); - } - - /** - * Internal method to execute a ping operation on a WebSocket connection after a delay of 60 seconds. - * - * @param {string} identifier - The identifier of the client connection. - * @param {WebSocket} ws - The WebSocket connection to ping. - * @return {void} This function does not return anything. - */ - private _ping(identifier: string, ws: WebSocket): void { - setTimeout(() => { - this._getClientConnection(identifier).then(clientConnection => { - if (clientConnection && clientConnection.isAlive) { - this._logger.debug("Pinging client", clientConnection.identifier); - // Set isAlive to false and send ping to client - clientConnection.isAlive = false; - this._cache.set(clientConnection.identifier, JSON.stringify(instanceToPlain(clientConnection)), CacheNamespace.Connections).then(() => { - ws.ping(); - }); - } else { - ws.close(1011, "Client is not alive"); - } - }); - }, this._config.websocket.pingInterval * 1000); - } - /** - * - * @param url Http upgrade request url used by charger - * @returns Charger identifier - */ - private _getClientIdFromUrl(url: string): string { - return url.split("/")[1]; - } - - private async _sendCallIsAllowed(identifier: string, message: Call): Promise { - const status = await this._cache.get(ConfigurationModule.BOOT_STATUS, identifier); - if (status == RegistrationStatusEnumType.Rejected && - // TriggerMessage is the only message allowed to be sent during Rejected BootStatus B03.FR.08 - !(message[2] as CallAction == CallAction.TriggerMessage && (message[3] as TriggerMessageRequest).requestedMessage == MessageTriggerEnumType.BootNotification)) { - return false; - } - return true; - } -} \ No newline at end of file diff --git a/Swarm/unix-init-install-all.sh b/Swarm/unix-init-install-all.sh index f48fcbcf8..124c9d2a2 100644 --- a/Swarm/unix-init-install-all.sh +++ b/Swarm/unix-init-install-all.sh @@ -42,6 +42,17 @@ util_commands=( "npm pack" ) +ocpprouter_commands=( + "cd ../03_Modules/OcppRouter" + "rm -rf ./lib" + "rm -f citrineos-ocpprouter-1.0.0.tgz" + "npm install ../../00_Base/citrineos-base-1.0.0.tgz" + "npm install ../../01_Data/citrineos-data-1.0.0.tgz" + "npm install ../../02_Util/citrineos-util-1.0.0.tgz" + "npm install" + "npm pack" +) + certificates_commands=( "cd ../03_Modules/Certificates" "rm -rf ./lib" @@ -125,6 +136,7 @@ ocpp_server_commands=( "npm install ../00_Base/citrineos-base-1.0.0.tgz" "npm install ../01_Data/citrineos-data-1.0.0.tgz" "npm install ../02_Util/citrineos-util-1.0.0.tgz" + "npm install ../03_Modules/OcppRouter/citrineos-ocpprouter-1.0.0.tgz" "npm install ../03_Modules/Certificates/citrineos-certificates-1.0.0.tgz" "npm install ../03_Modules/Configuration/citrineos-configuration-1.0.0.tgz" "npm install ../03_Modules/EVDriver/citrineos-evdriver-1.0.0.tgz" @@ -139,6 +151,8 @@ ocpp_server_commands=( execute_commands "${base_commands[@]}" execute_commands "${data_commands[@]}" execute_commands "${util_commands[@]}" +execute_commands "${ocpprouter_commands[@]}"& +pid_ocpprouter=$! execute_commands "${certificates_commands[@]}"& pid_certificates=$! execute_commands "${configuration_commands[@]}"& @@ -156,6 +170,7 @@ pid_transactions=$! +wait $pid_ocpprouter wait $pid_certificates wait $pid_configuration wait $pid_evdriver diff --git a/package.json b/package.json new file mode 100644 index 000000000..376eec40e --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "@citrineos/workspace", + "version": "1.0.0", + "private": true, + "devDependencies": { + "@types/node": "^20.11.20", + "concurrently": "^8.2.2", + "nodemon": "^3.1.0", + "typescript": "^5.3.3" + }, + "scripts": { + "install-all": "npm i --verbose", + "clean": "rm -rf package-lock.json **/package-lock.json **/**/package-lock.json dist **/dist **/**/dist node_modules **/node_modules **/**/node_modules tsconfig.tsbuildinfo **/tsconfig.tsbuildinfo **/**/tsconfig.tsbuildinfo", + "build": "tsc --build --verbose" + }, + "dependencies": { + "@citrineos/server": "1.0.0" + }, + "workspaces": [ + "./00_Base", + "./01_Data", + "./02_Util", + "./03_Modules/Certificates", + "./03_Modules/Configuration", + "./03_Modules/EVDriver", + "./03_Modules/Monitoring", + "./03_Modules/OcppRouter", + "./03_Modules/Reporting", + "./03_Modules/SmartCharging", + "./03_Modules/Transactions", + "./Server" + ] +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 000000000..2c4fd9fa8 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,22 @@ +{ + "exclude": [ + "**/*.test.ts", + "**/*.stub.ts", + "node_modules", + "**/__tests__/*", + "dist" + ], + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "sourceMap": true + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..59af615aa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "rootDir": "./", + "outDir": "./dist/" + }, + "exclude": ["DirectusExtensions"], + "references": [ + { + "path": "./Server" + } + ] +} \ No newline at end of file