From e0aaf23b7b8c9df6292f52120834e41e2c055cf9 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sat, 12 Oct 2024 18:54:00 +0200 Subject: [PATCH 1/5] feat: initial support of Get/ChangeConfiguration --- src/components/ChargePoint.tsx | 8 +- src/components/TopPage.tsx | 2 +- src/cp/ChargePoint.ts | 38 ++- src/cp/Configuration.ts | 435 +++++++++++++++++++++++++++++++++ src/cp/OCPPMessageHandler.ts | 106 ++++++-- src/cp/OCPPWebSocket.ts | 47 ++-- src/cp/OcppTypes.ts | 15 ++ 7 files changed, 606 insertions(+), 45 deletions(-) create mode 100644 src/cp/Configuration.ts diff --git a/src/components/ChargePoint.tsx b/src/components/ChargePoint.tsx index 27e53ae..95dcb7a 100644 --- a/src/components/ChargePoint.tsx +++ b/src/components/ChargePoint.tsx @@ -156,11 +156,11 @@ const ChargePointControls: React.FC = ({ } }; - const handleHeartbeatInterval = (isEnalbe: boolean) => { - setIsHeartbeatEnabled(isEnalbe); + const handleHeartbeatInterval = (isEnable: boolean) => { + setIsHeartbeatEnabled(isEnable); if (cp) { - if (isEnalbe) { - cp.startHeartbeat(10); + if (isEnable) { + cp.startHeartbeat(); } else { cp.stopHeartbeat(); } diff --git a/src/components/TopPage.tsx b/src/components/TopPage.tsx index 7bca3f4..abefad1 100644 --- a/src/components/TopPage.tsx +++ b/src/components/TopPage.tsx @@ -89,7 +89,7 @@ const ExperimentalView: React.FC = ({cps, tagIDs}) => { setIsAllHeartbeatEnabled(isEnalbe); if (isEnalbe) { cps.forEach((cp) => { - cp.startHeartbeat(10); + cp.startHeartbeat(); }); } else { cps.forEach((cp) => { diff --git a/src/cp/ChargePoint.ts b/src/cp/ChargePoint.ts index 0e6a934..e50e7c2 100644 --- a/src/cp/ChargePoint.ts +++ b/src/cp/ChargePoint.ts @@ -21,6 +21,7 @@ export class ChargePoint { }; private _heartbeat: number | null = null; + private _heartbeatPeriod = 10; private _autoMeterValueIntervals: Map = new Map(); private _statusChangeCallback: @@ -70,6 +71,10 @@ export class ChargePoint { return this._webSocket.url; } + public set wsUrl(value: string) { + this._webSocket.url = value; + } + get error(): string { return this._error; } @@ -153,6 +158,11 @@ export class ChargePoint { this._webSocket.disconnect(); } + public reset(): void { + this.disconnect(); + this.connect(); + } + public authorize(tagId: string): void { this._messageHandler.authorize(tagId); } @@ -214,18 +224,40 @@ export class ChargePoint { this._messageHandler.sendHeartbeat(); } - public startHeartbeat(period: number): void { - this._logger.info("Setting heartbeat period to " + period + "s"); + public get heartbeatPeriod(): number { + return this._heartbeatPeriod; + } + + public set heartbeatPeriod(value: number|string) { + if (typeof value === 'string') { + value = Number(value); + } + if (value <= 0) { + throw new Error("Invalid heartbeat period value"); + } + if (value === this._heartbeat) { + return; + } + this._heartbeatPeriod = value; + if (this._heartbeat) { + this.stopHeartbeat(); + this.startHeartbeat(); + } + } + + public startHeartbeat(): void { + this._logger.info("Setting heartbeat period to " + this._heartbeatPeriod + "s"); if (this._heartbeat) { clearInterval(this._heartbeat); } - this._heartbeat = setInterval(() => this.sendHeartbeat(), period * 1000); + this._heartbeat = setInterval(() => this.sendHeartbeat(), this._heartbeatPeriod * 1000); } public stopHeartbeat(): void { this._logger.info("Stopping heartbeat"); if (this._heartbeat) { clearInterval(this._heartbeat); + this._heartbeat = null; } } diff --git a/src/cp/Configuration.ts b/src/cp/Configuration.ts new file mode 100644 index 0000000..8af952f --- /dev/null +++ b/src/cp/Configuration.ts @@ -0,0 +1,435 @@ +import {ChargePoint} from "./ChargePoint.ts"; + +export const ConfigurationKeys = { + Core: { + // If this key exists, the Charge Point supports Unknown Offline Authorization. + // If this key reports a value of true, Unknown Offline Authorization is enabled. + AllowOfflineTxForUnknownId: { + name: "AllowOfflineTxForUnknownId", + required: false, + readonly: false, + type: "boolean", + } as BooleanConfigurationKey, + // If this key exists, the Charge Point supports an Authorization Cache. + // If this key reports a value of true, the Authorization Cache is enabled. + AuthorizationCacheEnabled: { + name: "AuthorizationCacheEnabled", + required: false, + readonly: false, + type: "boolean", + } as BooleanConfigurationKey, + // Whether a remote request to start a transaction in the form of a RemoteStartTransaction.req message should be + // authorized beforehand like a local action to start a transaction. + AuthorizeRemoteTxRequests: { + name: "AuthorizeRemoteTxRequests", + required: true, + readonly: true, // Choice is up to Charge Point implementation + type: "boolean", + } as BooleanConfigurationKey, + // Number of times to blink Charge Point lighting when signalling + BlinkRepeat: { + name: "BlinkRepeat", + required: false, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + // Size (in seconds) of the clock-aligned data interval. This is the size (in seconds) of the set of evenly spaced + // aggregation intervals per day, starting at 00:00:00 (midnight). + // For example, a value of 900 (15 minutes) indicates that every day should be broken into 96 15-minute intervals. + // When clock aligned data is being transmitted, the interval in question is identified by the start time + // and (optional) duration interval value, represented according to the ISO8601 standard. + // All "per-period" data (e.g. energy readings) should be accumulated (for "flow" type measurands such as energy), + // or averaged (for other values) across the entire interval (or partial interval, at the beginning + // or end of a Transaction), and transmitted (if so enabled) at the end of each interval, + // bearing the interval start time timestamp. + // A value of "0" (numeric zero), by convention, is to be interpreted to mean that no clock-aligned data + // should be transmitted. + ClockAlignedDataInterval: { + name: "ClockAlignedDataInterval", + required: true, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + // Interval (in seconds) *from beginning of status: 'Preparing' until incipient Transaction is automatically canceled, + // due to failure of EV driver to (correctly) insert the charging cable connector(s) into the appropriate socket(s). + // The Charge Point SHALL go back to the original state, probably: 'Available'. + ConnectionTimeOut: { + name: "ConnectionTimeOut", + required: true, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + // The phase rotation per connector in respect to the connector’s electrical meter (or if absent, the grid connection). + // Possible values per connector are: + // NotApplicable (for Single phase or DC Charge Points) + // Unknown (not (yet) known) + // RST (Standard Reference Phasing) + // RTS (Reversed Reference Phasing) + // SRT (Reversed 240 degree rotation) + // STR (Standard 120 degree rotation) + // TRS (Standard 240 degree rotation) + // TSR (Reversed 120 degree rotation) + // R can be identified as phase 1 (L1), S as phase 2 (L2), T as phase 3 (L3). + // If known, the Charge Point MAY also report the phase rotation between the grid connection and the main energymeter + // by using index number Zero (0). + // Values are reported in CSL, formatted: 0.RST, 1.RST, 2.RTS + ConnectorPhaseRotation: { + name: "ConnectorPhaseRotation", + required: true, + readonly: false, + type: "array", + } as ArrayConfigurationKey, + // Maximum number of items in a ConnectorPhaseRotation Configuration Key. + ConnectorPhaseRotationMaxLength: { + name: "ConnectorPhaseRotationMaxLength", + required: false, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + // Maximum number of requested configuration keys in a GetConfiguration.req PDU. + GetConfigurationMaxKeys: { + name: "GetConfigurationMaxKeys", + required: true, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + // Interval (in seconds) of inactivity (no OCPP exchanges) with central system after which the Charge Point + // should send a Heartbeat.req PDU + HeartbeatInterval: { + name: "HeartbeatInterval", + required: true, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + // Percentage of maximum intensity at which to illuminate Charge Point lighting + LightIntensity: { + name: "LightIntensity", + required: false, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + // whether the Charge Point, when offline, will start a transaction for locally-authorized identifiers. + LocalAuthorizeOffline: { + name: "LocalAuthorizeOffline", + required: true, + readonly: false, + type: "boolean", + } as BooleanConfigurationKey, + // whether the Charge Point, when online, will start a transaction for locally-authorized identifiers + // without waiting for or requesting an Authorize.conf from the Central System + LocalPreAuthorize: { + name: "LocalPreAuthorize", + required: true, + readonly: false, + type: "boolean", + } as BooleanConfigurationKey, + // Maximum energy (in Wh) delivered when an identifier is invalidated by the Central System after start of a transaction. + MaxEnergyOnInvalidId: { + name: "MaxEnergyOnInvalidId", + required: false, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + // Clock-aligned measurand(s) to be included in a MeterValues.req PDU, every ClockAlignedDataInterval seconds + MeterValuesAlignedData: { + name: "MeterValuesAlignedData", + required: true, + readonly: true, + type: "array", + } as ArrayConfigurationKey, + // Maximum number of items in a MeterValuesAlignedData Configuration Key. + MeterValuesAlignedDataMaxLength: { + name: "MeterValuesAlignedDataMaxLength", + required: false, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + // Sampled measurands to be included in a MeterValues.req PDU, every MeterValueSampleInterval seconds. + // Where applicable, the Measurand is combined with the optional phase; for instance: Voltage.L1 + // Default: "Energy.Active.Import.Register" + MeterValuesSampledData: { + name: "MeterValuesSampledData", + required: true, + readonly: false, + type: "array", + } as ArrayConfigurationKey, + // Maximum number of items in a MeterValuesSampledData Configuration Key. + MeterValuesSampledDataMaxLength: { + name: "MeterValuesSampledDataMaxLength", + required: false, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + // Interval (in seconds) between sampling of metering (or other) data, intended to be transmitted + // by "MeterValues" PDUs. For charging session data (ConnectorId>0), samples are acquired and transmitted + // periodically at this interval from the start of the charging transaction. + // A value of "0" (numeric zero), by convention, is to be interpreted to mean that no sampled data should be transmitted. + MeterValueSampleInterval: { + name: "MeterValueSampleInterval", + required: true, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + // The minimum duration (in seconds) that a Charge Point or Connector status is stable before + // a StatusNotification.req PDU is sent to the Central System. + MinimumStatusDuration: { + name: "MinimumStatusDuration", + required: false, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + // The number of physical charging connectors of this Charge Point. + NumberOfConnectors: { + name: "NumberOfConnectors", + required: true, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + // Number of times to retry an unsuccessful reset of the Charge Point. + ResetRetries: { + name: "ResetRetries", + required: true, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + // When set to true, the Charge Point SHALL administratively stop the transaction + // when the cable is unplugged from the EV. + StopTransactionOnEVSideDisconnect: { + name: "StopTransactionOnEVSideDisconnect", + required: true, + readonly: false, + type: "boolean", + } as BooleanConfigurationKey, + // whether the Charge Point will stop an ongoing transaction when it receives a non-Accepted authorization status + // in a StartTransaction.conf for this transaction + StopTransactionOnInvalidId: { + name: "StopTransactionOnInvalidId", + required: true, + readonly: false, + type: "boolean", + } as BooleanConfigurationKey, + // Clock-aligned periodic measurand(s) to be included in the TransactionData element of StopTransaction.req + // MeterValues.req PDU for every ClockAlignedDataInterval of the Transaction + StopTxnAlignedData: { + name: "StopTxnAlignedData", + required: true, + readonly: false, + type: "array", + } as ArrayConfigurationKey, + // Maximum number of items in a StopTxnAlignedData Configuration Key. + StopTxnAlignedDataMaxLength: { + name: "StopTxnAlignedDataMaxLength", + required: false, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + // Sampled measurands to be included in the TransactionData element of StopTransaction.req PDU, every + // MeterValueSampleInterval seconds from the start of the charging session + StopTxnSampledData: { + name: "StopTxnSampledData", + required: true, + readonly: false, + type: "array", + } as ArrayConfigurationKey, + // Maximum number of items in a StopTxnSampledData Configuration Key. + StopTxnSampledDataMaxLength: { + name: "StopTxnSampledDataMaxLength", + required: false, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + // A list of supported Feature Profiles. + // Possible profile identifiers: Core, FirmwareManagement, LocalAuthListManagement, Reservation, + // SmartCharging and RemoteTrigger. + SupportedFeatureProfiles: { + name: "SupportedFeatureProfiles", + required: true, + readonly: true, + type: "array", + } as ArrayConfigurationKey, + // Maximum number of items in a SupportedFeatureProfiles Configuration Key. + SupportedFeatureProfilesMaxLength: { + name: "SupportedFeatureProfilesMaxLength", + required: false, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + // How often (in times) the Charge Point should try to submit a transaction-related message when the Central System fails to process it. + TransactionMessageAttempts: { + name: "TransactionMessageAttempts", + required: true, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + // How long (in seconds) the Charge Point should wait before resubmitting a transaction-related message + // that the Central System failed to process. + TransactionMessageRetryInterval: { + name: "TransactionMessageRetryInterval", + required: true, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + // When set to true, the Charge Point SHALL unlock the cable on Charge Point side when the cable is unplugged at the EV. + UnlockConnectorOnEVSideDisconnect: { + name: "UnlockConnectorOnEVSideDisconnect", + required: true, + readonly: false, + type: "boolean", + } as BooleanConfigurationKey, + // Only relevant for websocket implementations. 0 disables client side websocket Ping/Pong. In this case there is either no + // ping/pong or the server initiates the ping and client responds with Pong. Positive values are interpreted as number of seconds + // between pings. Negative values are not allowed. ChangeConfiguration is expected to return a REJECTED result. + WebSocketPingInterval: { + name: "WebSocketPingInterval", + required: false, + readonly: false, + type: "integer", + } as IntegerConfigurationKey, + }, + Reservation: { + // If this configuration key is present and set to true: Charge Point support reservations on connector 0. + ReserveConnectorZeroSupported: { + name: "ReserveConnectorZeroSupported", + required: false, + readonly: true, + type: "boolean", + } as BooleanConfigurationKey, + }, + LocalAuthListManagement: { + // whether the Local Authorization List is enabled + LocalAuthListEnabled: { + name: "LocalAuthListEnabled", + required: true, + readonly: false, + type: "boolean", + } as BooleanConfigurationKey, + // Maximum number of identifications that can be stored in the Local Authorization List + LocalAuthListMaxLength: { + name: "LocalAuthListMaxLength", + required: true, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + // Maximum number of identifications that can be send in a single SendLocalList.req + SendLocalListMaxLength: { + name: "SendLocalListMaxLength", + required: true, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + }, + SmartCharging: { + // Max StackLevel of a ChargingProfile. The number defined also indicates the max allowed number of installed charging + // schedules per Charging Profile Purposes. + ChargeProfileMaxStackLevel: { + name: "ChargeProfileMaxStackLevel", + required: true, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + // A list of supported quantities for use in a ChargingSchedule. Allowed values: 'Current' and 'Power' + ChargingScheduleAllowedChargingRateUnit: { + name: "ChargingScheduleAllowedChargingRateUnit", + required: true, + readonly: true, + type: "array", + } as ArrayConfigurationKey, + // Maximum number of periods that may be defined per ChargingSchedule. + ChargingScheduleMaxPeriods: { + name: "ChargingScheduleMaxPeriods", + required: true, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + // If defined and true, this Charge Point support switching from 3 to 1 phase during a Transaction. + ConnectorSwitch3to1PhaseSupported: { + name: "ConnectorSwitch3to1PhaseSupported", + required: false, + readonly: true, + type: "boolean", + } as BooleanConfigurationKey, + // Maximum number of Charging profiles installed at a time + MaxChargingProfilesInstalled: { + name: "MaxChargingProfilesInstalled", + required: true, + readonly: true, + type: "integer", + } as IntegerConfigurationKey, + }, + Custom: { + OcppServer: { + name: "OcppServer", + required: false, + readonly: false, + type: "string", + } as StringConfigurationKey, + }, +}; + +export type IntegerConfigurationKey = { + name: string; + readonly: boolean; + required: boolean; + type: "integer"; +} +export type StringConfigurationKey = { + name: string; + readonly: boolean; + required: boolean; + type: "string"; +} +export type BooleanConfigurationKey = { + name: string; + readonly: boolean; + required: boolean; + type: "boolean"; +} +export type ArrayConfigurationKey = { + name: string; + readonly: boolean; + required: boolean; + type: "array"; +} + +export type ConfigurationValue = + | IntegerConfigurationValue + | StringConfigurationValue + | BooleanConfigurationValue + | ArrayConfigurationValue +export type IntegerConfigurationValue = { + key: IntegerConfigurationKey; + value: number; +} +export type StringConfigurationValue = { + key: StringConfigurationKey; + value: string; +} +export type BooleanConfigurationValue = { + key: BooleanConfigurationKey; + value: boolean; +} +export type ArrayConfigurationValue = { + key: ArrayConfigurationKey; + value: string[]; +} + +export type Configuration = ConfigurationValue[] + +export const defaultConfiguration: (cp: ChargePoint) => Configuration = (cp) => [ + { + key: ConfigurationKeys.Core.SupportedFeatureProfiles, + value: ["Core"], + }, + { + key: ConfigurationKeys.Core.NumberOfConnectors, + value: cp.connectorNumber, + }, + { + key: ConfigurationKeys.Core.HeartbeatInterval, + value: cp.heartbeatPeriod, + }, + { + key: ConfigurationKeys.Custom.OcppServer, + value: cp.wsUrl, + }, +]; diff --git a/src/cp/OCPPMessageHandler.ts b/src/cp/OCPPMessageHandler.ts index a79377a..d2ca88c 100644 --- a/src/cp/OCPPMessageHandler.ts +++ b/src/cp/OCPPMessageHandler.ts @@ -1,22 +1,28 @@ -import {OcppMessageRequestPayload, OcppMessageResponsePayload, OCPPWebSocket} from "./OCPPWebSocket"; +import {OcppMessageRequestPayload, OcppMessageResponsePayload, OCPPWebSocket, OcppMessagePayload} from "./OCPPWebSocket"; import {ChargePoint} from "./ChargePoint"; import {Transaction} from "./Transaction"; import {Logger} from "./Logger"; -import {OCPPMessageType, OCPPAction, OCPPStatus, BootNotification, OCPPErrorCode} from "./OcppTypes"; +import { + OCPPMessageType, + OCPPAction, + OCPPStatus, + BootNotification, + OCPPErrorCode, + OcppConfigurationKey +} from "./OcppTypes"; +import {UploadFile} from "./file_upload.ts"; +import {Configuration, ConfigurationKeys, defaultConfiguration} from "./Configuration.ts"; import * as request from "@voltbras/ts-ocpp/dist/messages/json/request"; import * as response from "@voltbras/ts-ocpp/dist/messages/json/response"; -import {OcppMessagePayload} from "./OCPPWebSocket"; - type OcppMessagePayloadCall = | request.RemoteStartTransactionRequest | request.RemoteStopTransactionRequest | request.ResetRequest | request.GetDiagnosticsRequest - | request.TriggerMessageRequest; - - + | request.TriggerMessageRequest + | request.ChangeConfigurationRequest; type OcppMessagePayloadCallResult = | response.AuthorizeResponse @@ -27,10 +33,6 @@ type OcppMessagePayloadCallResult = | response.StatusNotificationResponse | response.StopTransactionResponse; - - -import {UploadFile} from "./file_upload.ts"; - interface OCPPRequest { type: OCPPMessageType; action: OCPPAction; @@ -199,7 +201,7 @@ export class OCPPMessageHandler { connectorId?: number ): void { this._requests.add({type, action, id, payload, connectorId}); - this._webSocket.send(type, id, action, payload); + this._webSocket.sendAction(type, id, action, payload); } private handleIncomingMessage( @@ -252,6 +254,12 @@ export class OCPPMessageHandler { case OCPPAction.TriggerMessage: response = this.handleTriggerMessage(payload as request.TriggerMessageRequest); break; + case OCPPAction.GetConfiguration: + response = this.handleGetConfiguration(payload as request.GetConfigurationRequest); + break; + case OCPPAction.ChangeConfiguration: + response = this.handleChangeConfiguration(payload as request.ChangeConfigurationRequest); + break; default: this._logger.error(`Unsupported action: ${action}`); this.sendCallError( @@ -357,6 +365,7 @@ export class OCPPMessageHandler { private handleReset(payload: request.ResetRequest): response.ResetResponse { this._logger.log(`Reset request received: ${payload.type}`); + // TODO it should be called after sending the answer this._chargePoint.reset(); return {status: "Accepted"}; } @@ -371,6 +380,69 @@ export class OCPPMessageHandler { return {fileName: "diagnostics.txt"}; } + private handleGetConfiguration( + payload: request.GetConfigurationRequest + ): response.GetConfigurationResponse { + this._logger.log(`Get configuration request received: ${JSON.stringify(payload.key)}`); + const configuration = OCPPMessageHandler.mapConfiguration(defaultConfiguration(this._chargePoint)); + if (!payload.key || payload.key.length === 0) { + return { + configurationKey: configuration, + }; + } + const filteredConfig = configuration.filter((c) => payload.key?.includes(c.key)); + const configurationKeys = configuration.map((c) => c.key); + const unknownKeys = payload.key.filter((c) => !configurationKeys.includes(c)); + return { + configurationKey: filteredConfig, + unknownKey: unknownKeys, + } + } + + private static mapConfiguration(config: Configuration): OcppConfigurationKey[] { + return config.map(c => ({ + key: c.key.name, + readonly: c.key.readonly, + value: String(c.value), + })); + } + + private handleChangeConfiguration( + payload: request.ChangeConfigurationRequest + ): response.ChangeConfigurationResponse { + this._logger.log(`Change configuration request received: ${JSON.stringify(payload.key)}: ${JSON.stringify(payload.value)}`); + switch (payload.key) { + case ConfigurationKeys.Core.HeartbeatInterval.name: + try { + this._chargePoint.heartbeatPeriod = payload.value; + return { + status: "Accepted", + }; + } catch (e) { + this._logger.log(`Bad heartbeat period: ${e}`); + return { + status: "Rejected", + } + } + case ConfigurationKeys.Custom.OcppServer.name: + try { + this._chargePoint.wsUrl = payload.value; + return { + status: "RebootRequired", + }; + } catch (e) { + this._logger.log(`Bad url: ${e}`); + return { + status: "Rejected", + } + } + default: + return { + status: "NotSupported", + }; + } + } + private handleTriggerMessage( payload: request.TriggerMessageRequest ): response.TriggerMessageResponse { @@ -463,11 +535,10 @@ export class OCPPMessageHandler { } private sendCallResult(messageId: string, payload: OcppMessageResponsePayload): void { - this._webSocket.send( + this._webSocket.sendResult( OCPPMessageType.CALL_RESULT, messageId, - "" as OCPPAction, - payload + payload, ); } @@ -480,11 +551,10 @@ export class OCPPMessageHandler { errorCode: errorCode, errorDescription: errorDescription, }; - this._webSocket.send( + this._webSocket.sendResult( OCPPMessageType.CALL_ERROR, messageId, - "" as OCPPAction, - errorDetails + errorDetails, ); } diff --git a/src/cp/OCPPWebSocket.ts b/src/cp/OCPPWebSocket.ts index dc36913..9f601cd 100644 --- a/src/cp/OCPPWebSocket.ts +++ b/src/cp/OCPPWebSocket.ts @@ -16,6 +16,8 @@ export type OcppMessageRequestPayload = export type OcppMessageResponsePayload = | response.GetDiagnosticsResponse + | response.GetConfigurationResponse + | response.ChangeConfigurationResponse | response.RemoteStartTransactionResponse | response.RemoteStopTransactionResponse | response.ResetResponse @@ -36,8 +38,7 @@ type MessageHandler = ( export class OCPPWebSocket { private _ws: WebSocket | null = null; - private _url: string; - private _basicAuth: {username: string; password: string} | null = null; + private _url: URL; private _chargePointId: string; private _logger: Logger; private _messageHandler: MessageHandler | null = null; @@ -48,32 +49,29 @@ export class OCPPWebSocket { constructor(url: string, chargePointId: string, logger: Logger, basicAuthSettings: { username: string; password: string } | null = null) { - this._url = url; - this._chargePointId = chargePointId; - this._logger = logger; + this._url = new URL(url); if (basicAuthSettings) { - this._basicAuth = { - username: basicAuthSettings.username, - password: basicAuthSettings.password - }; + this._url.username = basicAuthSettings.username; + this._url.password = basicAuthSettings.password; } + this._chargePointId = chargePointId; + this._logger = logger; } get url(): string { - return this._url; + return this._url.toString(); + } + + set url(url: string) { + this._url = new URL(url); } public connect( onopen: (() => void) | null = null, onclose: ((ev: CloseEvent) => void) | null = null ): void { - const url = new URL(this._url); - if (this?._basicAuth) { - url.username = this._basicAuth.username; - url.password = this._basicAuth.password; - } - console.log("url", url); - this._ws = new WebSocket(`${url.toString()}${this._chargePointId}`, [ + console.log("url", this._url); + this._ws = new WebSocket(`${this._url.toString()}${this._chargePointId}`, [ "ocpp1.6", "ocpp1.5", ]); @@ -104,14 +102,25 @@ export class OCPPWebSocket { } } - public send( + public sendAction( messageType: OCPPMessageType, messageId: string, action: OCPPAction, payload: OcppMessagePayload ): void { + this.send(JSON.stringify([messageType, messageId, action, payload])); + } + + public sendResult( + messageType: OCPPMessageType, + messageId: string, + payload: OcppMessagePayload + ): void { + this.send(JSON.stringify([messageType, messageId, payload])); + } + + private send(message: string): void { if (this._ws && this._ws.readyState === WebSocket.OPEN) { - const message = JSON.stringify([messageType, messageId, action, payload]); this._ws.send(message); this._logger.log(`Sent: ${message}`); } else { diff --git a/src/cp/OcppTypes.ts b/src/cp/OcppTypes.ts index adb584c..adcd3a8 100644 --- a/src/cp/OcppTypes.ts +++ b/src/cp/OcppTypes.ts @@ -39,6 +39,21 @@ export enum OCPPAction { Heartbeat = "Heartbeat", Authorize = "Authorize", Reset = "Reset", + GetConfiguration = "GetConfiguration", + ChangeConfiguration = "ChangeConfiguration", +} + +export type OcppFeatureProfile = + | "Core" + | "FirmwareManagement" + | "LocalAuthListManagement" + | "Reservation" + | "SmartCharging" + | "RemoteTrigger"; +export type OcppConfigurationKey = { + key: string; + readonly: boolean; + value?: string; } export type OCPPErrorCode = ErrorCode; From 209f5c28089bb2f67e10dfcddf35a33639825431 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sat, 12 Oct 2024 19:10:06 +0200 Subject: [PATCH 2/5] feat: reset chargepoint after reset command --- src/cp/OCPPMessageHandler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cp/OCPPMessageHandler.ts b/src/cp/OCPPMessageHandler.ts index d2ca88c..7ba031b 100644 --- a/src/cp/OCPPMessageHandler.ts +++ b/src/cp/OCPPMessageHandler.ts @@ -365,7 +365,10 @@ export class OCPPMessageHandler { private handleReset(payload: request.ResetRequest): response.ResetResponse { this._logger.log(`Reset request received: ${payload.type}`); - // TODO it should be called after sending the answer this._chargePoint.reset(); + setTimeout(() => { + this._logger.log(`Reset chargePoint: ${this._chargePoint.id}`); + this._chargePoint.reset(); + }, 5_000); return {status: "Accepted"}; } From f57556ea3c43d1b518c83fa5eb1a3570e80a6012 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Tue, 15 Oct 2024 09:26:41 +0200 Subject: [PATCH 3/5] fix: default experimental/boot value is null --- src/components/Settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 984cfa2..e9a2945 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -62,7 +62,7 @@ const Settings: React.FC = () => { interval: autoMeterValueInterval, value: autoMeterValue }, - Experimental: experimental !== "" ? experimental && JSON.parse(experimental) : null, + Experimental: experimental && experimental !== "" ? JSON.parse(experimental) : null, BootNotification: bootNotification !== "" ? bootNotification && JSON.parse(bootNotification) : null, }); // navigate(`/${location.hash}`) From 30d516d29da5e2267959ba39d9ace46488274b46 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Tue, 15 Oct 2024 09:39:28 +0200 Subject: [PATCH 4/5] fix: url configuration when not set --- src/components/Settings.tsx | 6 +++--- src/cp/OCPPWebSocket.ts | 11 +++++++---- src/store/store.ts | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index e9a2945..a18d70e 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,5 +1,5 @@ import React, {useState, useEffect} from "react"; -import {configAtom} from "../store/store.ts"; +import {Config, configAtom} from "../store/store.ts"; import {useAtom} from "jotai/index"; import {DefaultBootNotification} from "../cp/OcppTypes.ts"; @@ -63,8 +63,8 @@ const Settings: React.FC = () => { value: autoMeterValue }, Experimental: experimental && experimental !== "" ? JSON.parse(experimental) : null, - BootNotification: bootNotification !== "" ? bootNotification && JSON.parse(bootNotification) : null, - }); + BootNotification: bootNotification && bootNotification !== "" ? JSON.parse(bootNotification) : null, + } as Config); // navigate(`/${location.hash}`) }; diff --git a/src/cp/OCPPWebSocket.ts b/src/cp/OCPPWebSocket.ts index 9f601cd..fb09371 100644 --- a/src/cp/OCPPWebSocket.ts +++ b/src/cp/OCPPWebSocket.ts @@ -38,7 +38,7 @@ type MessageHandler = ( export class OCPPWebSocket { private _ws: WebSocket | null = null; - private _url: URL; + private _url?: URL; private _chargePointId: string; private _logger: Logger; private _messageHandler: MessageHandler | null = null; @@ -49,8 +49,8 @@ export class OCPPWebSocket { constructor(url: string, chargePointId: string, logger: Logger, basicAuthSettings: { username: string; password: string } | null = null) { - this._url = new URL(url); - if (basicAuthSettings) { + this._url = url ? new URL(url) : undefined; + if (this._url && basicAuthSettings) { this._url.username = basicAuthSettings.username; this._url.password = basicAuthSettings.password; } @@ -59,7 +59,7 @@ export class OCPPWebSocket { } get url(): string { - return this._url.toString(); + return this._url?.toString() ?? ""; } set url(url: string) { @@ -70,6 +70,9 @@ export class OCPPWebSocket { onopen: (() => void) | null = null, onclose: ((ev: CloseEvent) => void) | null = null ): void { + if (!this._url) { + throw new Error("Could not connect to OCPP WebSocket client"); + } console.log("url", this._url); this._ws = new WebSocket(`${this._url.toString()}${this._chargePointId}`, [ "ocpp1.6", diff --git a/src/store/store.ts b/src/store/store.ts index 8f2f31b..e029cb7 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -4,7 +4,7 @@ import {atomWithHash} from 'jotai-location' import {BootNotification} from "../cp/OcppTypes.ts"; -interface Config { +export interface Config { wsURL: string; ChargePointID: string; connectorNumber: number; From d397a0b315e93620f13348715c1e3a92e71f82e8 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Tue, 15 Oct 2024 14:25:04 +0200 Subject: [PATCH 5/5] feat: use redirections --- src/components/Settings.tsx | 5 +++-- src/components/TopPage.tsx | 44 +++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index a18d70e..e7c9906 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -2,6 +2,7 @@ import React, {useState, useEffect} from "react"; import {Config, configAtom} from "../store/store.ts"; import {useAtom} from "jotai/index"; import {DefaultBootNotification} from "../cp/OcppTypes.ts"; +import {useNavigate} from "react-router-dom"; const Settings: React.FC = () => { const [wsURL, setWsURL] = useState(""); @@ -21,7 +22,7 @@ const Settings: React.FC = () => { const [experimental, setExperimental] = useState(null); const [bootNotification, setBootNotification] = useState(JSON.stringify(DefaultBootNotification)); const [config, setConfig] = useAtom(configAtom); - + const navigate = useNavigate(); useEffect(() => { if (config) { @@ -65,7 +66,7 @@ const Settings: React.FC = () => { Experimental: experimental && experimental !== "" ? JSON.parse(experimental) : null, BootNotification: bootNotification && bootNotification !== "" ? JSON.parse(bootNotification) : null, } as Config); - // navigate(`/${location.hash}`) + navigate("/"); }; return ( diff --git a/src/components/TopPage.tsx b/src/components/TopPage.tsx index abefad1..d67eb60 100644 --- a/src/components/TopPage.tsx +++ b/src/components/TopPage.tsx @@ -6,43 +6,45 @@ import {ChargePoint as OCPPChargePoint} from "../cp/ChargePoint.ts"; import {useAtom} from 'jotai' import {configAtom} from "../store/store.ts"; import {BootNotification, DefaultBootNotification} from "../cp/OcppTypes.ts"; - +import {useNavigate} from "react-router-dom"; const TopPage: React.FC = () => { const [cps, setCps] = useState([]); - const [connectorNumber, setConnectorNumber] = useState(2); const [config] = useAtom(configAtom); const [tagIDs, setTagIDs] = useState([]); + const navigate = useNavigate(); useEffect(() => { - console.log(`Connector Number: ${config?.connectorNumber} WSURL: ${config?.wsURL} CPID: ${config?.ChargePointID} TagID: ${config?.tagID}`); - if (config?.Experimental === null) { - setConnectorNumber(config?.connectorNumber || 2); - setCps([ - NewChargePoint(connectorNumber, config.ChargePointID, config.BootNotification ?? DefaultBootNotification, config.wsURL, config.basicAuthSettings,config.autoMeterValueSetting) - ]); - } else { - const cps = config?.Experimental?.ChargePointIDs.map((cp) => - NewChargePoint(cp.ConnectorNumber, cp.ChargePointID, config.BootNotification ?? DefaultBootNotification, config.wsURL,config.basicAuthSettings,config.autoMeterValueSetting) + if (!config) { + navigate('/settings'); + return; + } + console.log(`Connector Number: ${config.connectorNumber} WSURL: ${config.wsURL} CPID: ${config.ChargePointID} TagID: ${config.tagID}`); + if (config.Experimental) { + const cps = config.Experimental.ChargePointIDs.map((cp) => + NewChargePoint(cp.ConnectorNumber, cp.ChargePointID, config.BootNotification ?? DefaultBootNotification, config.wsURL, config.basicAuthSettings, config.autoMeterValueSetting) ) setCps(cps ?? []); - const tagIDs = config?.Experimental?.TagIDs; + const tagIDs = config.Experimental.TagIDs; setTagIDs(tagIDs ?? []); + } else { + setCps([ + NewChargePoint(config.connectorNumber, config.ChargePointID, config.BootNotification ?? DefaultBootNotification, config.wsURL, config.basicAuthSettings, config.autoMeterValueSetting) + ]); } - }, []); - + }, [config, navigate]); return (
{ - cps.length === 1 ? ( - <> - - + config?.Experimental || cps.length !== 1 ? ( + <> + + ) : ( - <> - - + <> + + ) }