diff --git a/app/components/evse.tsx b/app/components/evse.tsx index ff2aff1..eda2f82 100644 --- a/app/components/evse.tsx +++ b/app/components/evse.tsx @@ -7,25 +7,26 @@ import WebSocket from './WebSocket'; import { ChargingSocket, IChargingSocket } from '../service/ocpp/connector'; import { StatusNotification } from '../service/ocpp/status.notificiation'; import { HandleOcpp } from '../service/ocpp/ocpp.handler'; +import { SendBootNotification } from '../service/ocpp/command/boot-notification/bootnotification'; const defaultValue = 'ws://localhost:8080/ocpp/JwNpTpPxPm/CHR202305102'; export default function Evse() { const [url, setUrl] = useState(defaultValue); const [online, setOnline] = useState(false); - const writer = useRef([]); + const writer = useRef>([]); const chargingSocket = useRef( new ChargingSocket(StatusNotification.UNAVAILABLE) ); const onlineChange: ConState = (connected: boolean, w?: IWriter) => { + console.log(chargingSocket); + setOnline(connected); if (w != null) { writer.current.push(w); - // send BootNotification once during Boot up - w?.Write('hello'); - console.log(chargingSocket); + SendBootNotification(w); } }; diff --git a/app/helper/validation.helper.ts b/app/helper/validation.helper.ts new file mode 100644 index 0000000..0bc34a1 --- /dev/null +++ b/app/helper/validation.helper.ts @@ -0,0 +1,15 @@ +import { plainToClass } from 'class-transformer'; +import { validateSync, ValidationError } from 'class-validator'; + +type ValidationResult = [T, Array]; + +const defaultValue = { + enableDebugMessages: false, + validationError: { target: false }, +}; + +export default function Validate(c: any, obj: unknown): ValidationResult { + const result: object = plainToClass(c, obj); + const validation = validateSync(result, defaultValue); + return [result as T, validation]; +} diff --git a/app/page.tsx b/app/page.tsx index 0f96c1d..413b770 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,3 @@ -'use client'; - import Evse from './components/evse'; export default function Home() { diff --git a/app/service/ocpp/command/boot-notification/bootnotification.model.ts b/app/service/ocpp/command/boot-notification/bootnotification.model.ts new file mode 100644 index 0000000..e70d2c3 --- /dev/null +++ b/app/service/ocpp/command/boot-notification/bootnotification.model.ts @@ -0,0 +1,94 @@ +import { + IsNumber, + IsEnum, + Min, + Max, + MinLength, + MaxLength, + IsOptional, + IsString, +} from 'class-validator'; + +enum Status { + ACCEPTED = 'Accepted', + REJECTED = 'Rejected', + PENDING = 'Pending', +} + +interface IBootNotification { + chargePointVendor: string; + chargePointModel: string; + chargeBoxSerialNumber: string; + chargePointSerialNumber: string; + firmwareVersion: string; + iccid: string; + imsi: string; + meterSerialNumber: string; + meterType: string; +} +interface IBootNotificationRes { + currentTime: Date; + interval: number; + status: Status; +} + +class BootNotification implements IBootNotification { + @MinLength(1) + @MaxLength(20) + chargePointVendor = ''; + + @MinLength(1) + @MaxLength(20) + chargePointModel = ''; + + @MinLength(1) + @MaxLength(25) + @IsOptional() + chargeBoxSerialNumber = ''; + + @MinLength(1) + @MaxLength(25) + @IsOptional() + chargePointSerialNumber = ''; + + @MinLength(1) + @MaxLength(50) + @IsOptional() + firmwareVersion = ''; + + @MinLength(1) + @MaxLength(20) + @IsOptional() + iccid = ''; + + @MinLength(1) + @MaxLength(20) + @IsOptional() + imsi = ''; + + @MinLength(1) + @MaxLength(25) + @IsOptional() + meterSerialNumber = ''; + + @MinLength(1) + @MaxLength(25) + @IsOptional() + meterType = ''; +} + +class BootNotificationRes implements IBootNotificationRes { + @IsString() + currentTime: Date = new Date(); + + @IsNumber() + @Min(3600) + @Max(84000) + interval = 0; + + @IsEnum(Status) + status = Status.PENDING; +} + +export type { IBootNotification }; +export { Status, BootNotification, BootNotificationRes }; diff --git a/app/service/ocpp/command/boot-notification/bootnotification.ts b/app/service/ocpp/command/boot-notification/bootnotification.ts new file mode 100644 index 0000000..12448bf --- /dev/null +++ b/app/service/ocpp/command/boot-notification/bootnotification.ts @@ -0,0 +1,81 @@ +import { DEFAULT_TIMER, IWriter } from '../../../websocket/websocket.model'; +import { Action, CreateRequestFrame, GetRequestFrame } from '../../ocpp.action'; +import { IResponse } from '../../ocpp.frame'; +import { NewTransaction } from '../../transaction/transaction.handler'; +import { + BootNotificationRes, + IBootNotification, + Status, +} from './bootnotification.model'; +import { CreateError, ErrorCode } from '../../ocpp.error'; +import Validate from '@/app/helper/validation.helper'; + +const defaultValue: IBootNotification = { + chargePointVendor: 'EW', + chargePointModel: 'CHR22', + chargeBoxSerialNumber: 'CR-00', + chargePointSerialNumber: 'CR-00', + firmwareVersion: '', + iccid: '', + imsi: '', + meterSerialNumber: '', + meterType: 'power', +}; + +let id: ReturnType; +let success = false; + +function retry(w: IWriter): void { + id = setTimeout(() => { + try { + SendBootNotification(w); + } catch (error) { + clearTimeout(id); + } + }, DEFAULT_TIMER); +} + +function SendBootNotification(w: IWriter): void { + try { + clearTimeout(id); + if (success) return; + const frame = CreateRequestFrame(Action.BOOT_NOTIFICATION, defaultValue); + NewTransaction(GetRequestFrame(frame), BootNotification); + w.Write(frame); + } catch (error) { + retry(w); + } +} + +function BootNotification(w: IWriter, frame: IResponse): void { + clearTimeout(id); + const [result, validation] = Validate( + BootNotificationRes, + frame.payload + ); + + console.log(validation); + + if (validation.length > 0) { + w.Write(CreateError(ErrorCode.PropertyConstraintViolation, validation)); + return retry(w); + } + + if (result.status == Status.PENDING) { + w.Write( + CreateError(ErrorCode.NotSupported, { + err: 'Pending functionality is not supported', + }) + ); + + return retry(w); + } + + if (result.status == Status.REJECTED) { + return retry(w); + } + + success = true; +} + +export { SendBootNotification, BootNotification }; diff --git a/app/service/ocpp/connector.ts b/app/service/ocpp/connector.ts index df033a3..79cbb4e 100644 --- a/app/service/ocpp/connector.ts +++ b/app/service/ocpp/connector.ts @@ -6,6 +6,7 @@ interface IChargingSocket { } class ChargingSocket implements IChargingSocket { + booted = false; constructor(private state: StatusNotification) {} ChangeState(state: StatusNotification): void { diff --git a/app/service/ocpp/ocpp.action.ts b/app/service/ocpp/ocpp.action.ts index 6ca7361..f08d756 100644 --- a/app/service/ocpp/ocpp.action.ts +++ b/app/service/ocpp/ocpp.action.ts @@ -1,4 +1,14 @@ -import { IRequest, IResponse } from './ocpp.frame'; +import { v4 } from 'uuid'; +import { + BaseTuple, + CallType, + IRequest, + IResponse, + RequestTuple, + ResponseTuple, + UniqueID, +} from './ocpp.frame'; +import { ErrorCode } from './ocpp.error'; enum Action { BOOT_NOTIFICATION = 'BootNotification', @@ -10,20 +20,36 @@ enum Action { REMOTE_STOP_TRANSACTION = 'RemoteStopTransaction', } -function CreateRequestFrame(): void { - throw new Error('Not implemented'); +function CreateRequestFrame(action: Action, payload: T): RequestTuple { + return [CallType.CALL, v4(), action, payload]; } -function GetRequestFrame(): IRequest { - throw new Error('Not implemented'); +function GetRequestFrame(payload: RequestTuple | BaseTuple): IRequest { + if (payload.length < 4) throw new Error(ErrorCode.FormationViolation); + if (payload[0] != CallType.CALL) throw new Error(ErrorCode.ProtocolError); + + return { + messageTypeID: payload[0], + uuid: payload[1], + action: payload[2]!, + payload: payload[3], + }; } -function CreateResponseFrame(): void { - throw new Error('Not implemented'); +function CreateResponseFrame(uuid: UniqueID, payload: T): ResponseTuple { + return [CallType.CALL_RESULT, uuid, payload]; } -function GetResponseFrame(): IResponse { - throw new Error('Not implemented'); +function GetResponseFrame(payload: ResponseTuple | BaseTuple): IResponse { + if (payload.length < 3) throw new Error(ErrorCode.FormationViolation); + if (payload[0] != CallType.CALL_RESULT) + throw new Error(ErrorCode.ProtocolError); + + return { + messageTypeID: payload[0], + uuid: payload[1], + payload: payload[2], + }; } export { diff --git a/app/service/ocpp/ocpp.error.ts b/app/service/ocpp/ocpp.error.ts index bbe3c62..00a29e6 100644 --- a/app/service/ocpp/ocpp.error.ts +++ b/app/service/ocpp/ocpp.error.ts @@ -1,4 +1,4 @@ -import { BaseTuple, CallType, ErrorFrame, ErrorTuple } from './ocpp.frame'; +import { BaseTuple, CallType, IErrorFrame, ErrorTuple } from './ocpp.frame'; import { v4 } from 'uuid'; type ErrorDescription = string; @@ -32,7 +32,7 @@ function CreateError(errCode: ErrorCode, details: ErrorDetails): ErrorTuple { return [CallType.CALL_ERROR, v4(), code, errCode, details]; } -function GetError(payload: ErrorTuple | BaseTuple): ErrorFrame { +function GetError(payload: ErrorTuple | BaseTuple): IErrorFrame { if (payload.length != 5) throw new Error(ErrorCode.FormationViolation); if (payload[0] != CallType.CALL_ERROR) throw new Error(ErrorCode.FormationViolation); diff --git a/app/service/ocpp/ocpp.frame.ts b/app/service/ocpp/ocpp.frame.ts index 87b8555..73922fd 100644 --- a/app/service/ocpp/ocpp.frame.ts +++ b/app/service/ocpp/ocpp.frame.ts @@ -26,7 +26,7 @@ interface IPayload { payload: Payload; } -interface ErrorFrame extends IFrame { +interface IErrorFrame extends IFrame { errorCode: ErrorCode; errorDescription: ErrorDescription; errorDetails: unknown; @@ -40,7 +40,7 @@ interface IFrame { export type { IRequest, IResponse, - ErrorFrame, + IErrorFrame, Action, BaseTuple, UniqueID, diff --git a/app/service/ocpp/ocpp.handler.ts b/app/service/ocpp/ocpp.handler.ts index e74b13e..2a8444d 100644 --- a/app/service/ocpp/ocpp.handler.ts +++ b/app/service/ocpp/ocpp.handler.ts @@ -4,25 +4,27 @@ import { CreateError, ErrorCode, GetError } from './ocpp.error'; import { BaseTuple, CallType, - ErrorFrame, + IErrorFrame, IRequest, IResponse, } from './ocpp.frame'; +import { FindTransaction } from './transaction/transaction.handler'; -type OCPPData = IRequest | IResponse | ErrorFrame; +type OCPPData = IRequest | IResponse | IErrorFrame; function getCallType(frame: BaseTuple): CallType { return frame[0]; } -function getFullFrame(frame: BaseTuple): OCPPData { +function getFullFrame(frame: BaseTuple): [CallType, OCPPData] { let data: OCPPData; - switch (getCallType(frame)) { + const callType = getCallType(frame); + switch (callType) { case CallType.CALL: - data = GetRequestFrame(); + data = GetRequestFrame(frame); break; case CallType.CALL_RESULT: - data = GetResponseFrame(); + data = GetResponseFrame(frame); break; case CallType.CALL_ERROR: data = GetError(frame); @@ -30,10 +32,37 @@ function getFullFrame(frame: BaseTuple): OCPPData { default: throw new Error(ErrorCode.ProtocolError); } - return data; + + return [callType, data]; +} + +function processCall(frame: IRequest) { + console.log(frame); +} + +function processReturn(w: IWriter, frame: IErrorFrame | IResponse): void { + try { + const transaction = FindTransaction(frame.uuid); + if (frame.messageTypeID == CallType.CALL_ERROR) + transaction.AddError(frame as IErrorFrame); + else { + transaction.AddResponse(w, frame as IResponse); + } + } catch (error) { + console.log('Unable to process transaction'); + } +} + +function handleFrame(w: IWriter, frame: BaseTuple): void { + const [call, result] = getFullFrame(frame); + if (call == CallType.CALL) { + processCall(result as IRequest); + } else { + processReturn(w, result); + } } -function isValidFrame(frame: unknown[]): BaseTuple { +function isValidFrame(frame: Array): BaseTuple { const len = frame.length; if (len < 3 || len > 5) throw new Error(ErrorCode.ProtocolError); @@ -44,7 +73,7 @@ function isValidFrame(frame: unknown[]): BaseTuple { return frame as BaseTuple; } -function HandlerError(err: Error, w: IWriter): void { +function handlerError(err: Error, w: IWriter): void { const json = JSON.stringify(CreateError(err.message as ErrorCode, err)); w.Write(json); } @@ -54,9 +83,8 @@ export function HandleOcpp(w: IWriter, json: string): void { const data: unknown = JSON.parse(json); if (!Array.isArray(data)) throw new Error(ErrorCode.ProtocolError); let frame = isValidFrame(data); - const request = getFullFrame(frame); - console.info(request); + handleFrame(w, frame); } catch (error: unknown) { - HandlerError(error as Error, w); + handlerError(error as Error, w); } } diff --git a/app/service/ocpp/transaction/transaction.handler.ts b/app/service/ocpp/transaction/transaction.handler.ts new file mode 100644 index 0000000..b26b9e1 --- /dev/null +++ b/app/service/ocpp/transaction/transaction.handler.ts @@ -0,0 +1,20 @@ +import { IRequest } from '../ocpp.frame'; +import { ActionResponse, ITransaction, Transaction } from './transaction.model'; + +const List: Array = []; + +function NewTransaction(frame: IRequest, handler: ActionResponse): void { + List.push(new Transaction(frame, handler)); +} + +function FindTransaction(id: string): ITransaction { + const transaction = List.find((t) => t.GetID() == id); + if (transaction == null) throw new Error('transaction was not found'); + return transaction; +} + +function GetTransactions(): Array { + return List; +} + +export { GetTransactions, NewTransaction, FindTransaction }; diff --git a/app/service/ocpp/transaction/transaction.model.ts b/app/service/ocpp/transaction/transaction.model.ts new file mode 100644 index 0000000..8689b09 --- /dev/null +++ b/app/service/ocpp/transaction/transaction.model.ts @@ -0,0 +1,49 @@ +import { IWriter } from '../../websocket/websocket.model'; +import { IErrorFrame, IRequest, IResponse } from '../ocpp.frame'; + +type ActionResponse = (w: IWriter, frame: IResponse) => void; + +interface ITransaction { + GetID(): string; + Handler: ActionResponse; + AddResponse(w: IWriter, frame: IResponse): void; + GetResponse(): IResponse | undefined; + AddError(frame: IErrorFrame): void; + GetError(): IErrorFrame | undefined; +} + +class Transaction implements ITransaction { + private response?: IResponse; + private error?: IErrorFrame; + Handler: ActionResponse; + + constructor(private frame: IRequest, handler: ActionResponse) { + this.Handler = handler; + } + + AddError(frame: IErrorFrame): void { + this.error = frame; + } + + AddResponse(w: IWriter, frame: IResponse): void { + this.response = frame; + this.Handler(w, frame); + } + + GetID(): string { + return this.frame.uuid; + } + + GetError(): IErrorFrame | undefined { + if (this.error == null) return undefined; + return this.error; + } + + GetResponse(): IResponse | undefined { + if (this.response == null) return undefined; + return this.response; + } +} + +export type { ITransaction, ActionResponse }; +export { Transaction }; diff --git a/package-lock.json b/package-lock.json index 9314546..805a5f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "evse", "version": "0.1.0", "dependencies": { + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", "next": "14.2.3", "react": "^18", "react-dom": "^18", @@ -490,6 +492,11 @@ "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", "dev": true }, + "node_modules/@types/validator": { + "version": "13.11.9", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.9.tgz", + "integrity": "sha512-FCTsikRozryfayPuiI46QzH3fnrOoctTjvOYZkho9BTFLCOZ2rgZJHMOVgCOfttjPJcgOx52EpkY0CMfy87MIw==" + }, "node_modules/@typescript-eslint/parser": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", @@ -1090,6 +1097,21 @@ "node": ">= 6" } }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "node_modules/class-validator": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", + "integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.10.53", + "validator": "^13.9.0" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2968,6 +2990,11 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.10.62", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.62.tgz", + "integrity": "sha512-zbLf2yhgrs+TN4rHT7ral38WQEXjS4TWKp8QD3P5fJmHh3lCtTiPyr8XDPGaA7T41HDz2qxR7x3uwr+aNbShJQ==" + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -4611,6 +4638,14 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/validator": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 0133957..b434ff5 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "next lint" }, "dependencies": { + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", "next": "14.2.3", "react": "^18", "react-dom": "^18", diff --git a/tsconfig.json b/tsconfig.json index 39787cd..18f0285 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,8 @@ "noUnusedParameters": true, "jsx": "preserve", "incremental": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "plugins": [ { "name": "next"