From: Jérôme Benoit Date: Wed, 25 Mar 2026 18:20:36 +0000 (+0100) Subject: feat: add connector cable retention lock/unlock simulation (#1754) X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=1f7412b3178fa3497d12de97539ef3eddc6c717d;p=e-mobility-charging-stations-simulator.git feat: add connector cable retention lock/unlock simulation (#1754) * feat(types): add connector lock simulation types and UI protocol enums * feat(core): add connector lock/unlock simulation methods * feat(ocpp): set lock state on UnlockConnector command for both OCPP stacks * feat(ui-server): add lock/unlock broadcast channel commands and MCP schemas * feat(ui): add lock/unlock buttons to web UI * test: add connector lock/unlock simulation tests * docs: add connector lock/unlock simulation documentation * feat(ocpp): auto-lock connector on transaction start, auto-unlock on normal termination * fix(ui): reorder Locked column before Transaction in connector table * fix: guard lock unlock on accepted stop, emit updated event, add null status warnings, fix resetConnectorStatus test * [autofix.ci] apply automated fixes * fix(ocpp): only auto-lock connector on accepted transaction start in OCPP 2.0.1 * fix(core): emit connectorStatusChanged instead of updated on lock state change * test: add idempotency tests for lockConnector and unlockConnector --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- diff --git a/README.md b/README.md index a0ae13af..6cd19047 100644 --- a/README.md +++ b/README.md @@ -958,6 +958,40 @@ Set the WebSocket header _Sec-WebSocket-Protocol_ to `ui0.0.1`. `responsesFailed`: failed responses payload array (optional) } +###### Lock Connector + +- Request: + `ProcedureName`: 'lockConnector' + `PDU`: { + `hashIds`: charging station unique identifier strings array (optional, default: all charging stations), + `connectorId`: connector id integer + } + +- Response: + `PDU`: { + `status`: 'success' | 'failure', + `hashIdsSucceeded`: charging station unique identifier strings array, + `hashIdsFailed`: charging station unique identifier strings array (optional), + `responsesFailed`: failed responses payload array (optional) + } + +###### Unlock Connector + +- Request: + `ProcedureName`: 'unlockConnector' + `PDU`: { + `hashIds`: charging station unique identifier strings array (optional, default: all charging stations), + `connectorId`: connector id integer + } + +- Response: + `PDU`: { + `status`: 'success' | 'failure', + `hashIdsSucceeded`: charging station unique identifier strings array, + `hashIdsFailed`: charging station unique identifier strings array (optional), + `responsesFailed`: failed responses payload array (optional) + } + ###### OCPP commands trigger - Request: diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 605f77fc..33d20155 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -837,6 +837,33 @@ export class ChargingStation extends EventEmitter { return this.wsConnection?.readyState === WebSocket.OPEN } + public lockConnector (connectorId: number): void { + if (connectorId === 0) { + logger.warn(`${this.logPrefix()} lockConnector: connector id 0 is not a physical connector`) + return + } + if (!this.hasConnector(connectorId)) { + logger.warn( + `${this.logPrefix()} lockConnector: connector id ${connectorId.toString()} does not exist` + ) + return + } + const connectorStatus = this.getConnectorStatus(connectorId) + if (connectorStatus == null) { + logger.warn( + `${this.logPrefix()} lockConnector: connector id ${connectorId.toString()} status is null` + ) + return + } + if (connectorStatus.locked !== true) { + connectorStatus.locked = true + this.emitChargingStationEvent(ChargingStationEvents.connectorStatusChanged, { + connectorId, + ...connectorStatus, + }) + } + } + public logPrefix = (): string => { if ( this instanceof ChargingStation && @@ -1200,6 +1227,33 @@ export class ChargingStation extends EventEmitter { this.emitChargingStationEvent(ChargingStationEvents.updated) } + public unlockConnector (connectorId: number): void { + if (connectorId === 0) { + logger.warn(`${this.logPrefix()} unlockConnector: connector id 0 is not a physical connector`) + return + } + if (!this.hasConnector(connectorId)) { + logger.warn( + `${this.logPrefix()} unlockConnector: connector id ${connectorId.toString()} does not exist` + ) + return + } + const connectorStatus = this.getConnectorStatus(connectorId) + if (connectorStatus == null) { + logger.warn( + `${this.logPrefix()} unlockConnector: connector id ${connectorId.toString()} status is null` + ) + return + } + if (connectorStatus.locked !== false) { + connectorStatus.locked = false + this.emitChargingStationEvent(ChargingStationEvents.connectorStatusChanged, { + connectorId, + ...connectorStatus, + }) + } + } + private add (): void { this.emitChargingStationEvent(ChargingStationEvents.added) } diff --git a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts index 2b37a86f..74323b59 100644 --- a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts +++ b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts @@ -151,6 +151,17 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne this.passthrough(RequestCommand.GET_CERTIFICATE_STATUS), ], [BroadcastChannelProcedureName.HEARTBEAT, this.passthrough(RequestCommand.HEARTBEAT)], + [ + BroadcastChannelProcedureName.LOCK_CONNECTOR, + (requestPayload?: BroadcastChannelRequestPayload) => { + if (requestPayload?.connectorId == null) { + throw new BaseError( + `${this.chargingStation.logPrefix()} ${moduleName}.requestHandler: 'connectorId' field is required` + ) + } + this.chargingStation.lockConnector(requestPayload.connectorId) + }, + ], [ BroadcastChannelProcedureName.LOG_STATUS_NOTIFICATION, this.passthrough(RequestCommand.LOG_STATUS_NOTIFICATION), @@ -224,6 +235,17 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne BroadcastChannelProcedureName.TRANSACTION_EVENT, this.passthrough(RequestCommand.TRANSACTION_EVENT), ], + [ + BroadcastChannelProcedureName.UNLOCK_CONNECTOR, + (requestPayload?: BroadcastChannelRequestPayload) => { + if (requestPayload?.connectorId == null) { + throw new BaseError( + `${this.chargingStation.logPrefix()} ${moduleName}.requestHandler: 'connectorId' field is required` + ) + } + this.chargingStation.unlockConnector(requestPayload.connectorId) + }, + ], ]) this.onmessage = this.requestHandler.bind(this) as (message: unknown) => void this.onmessageerror = this.messageErrorHandler.bind(this) as (message: unknown) => void diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index 4021c0c5..49d1fe02 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -1589,6 +1589,7 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { connectorId, status: OCPP16ChargePointStatus.Available, } as OCPP16StatusNotificationRequest) + chargingStation.unlockConnector(connectorId) return OCPP16Constants.OCPP_RESPONSE_UNLOCKED } diff --git a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts index 827a85d2..826c8f81 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts @@ -381,6 +381,7 @@ export class OCPP16ResponseService extends OCPPResponseService { connectorStatus.transactionId = payload.transactionId connectorStatus.transactionIdTag = requestPayload.idTag connectorStatus.transactionEnergyActiveImportRegisterValue = 0 + connectorStatus.locked = true connectorStatus.transactionBeginMeterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue( chargingStation, @@ -516,7 +517,14 @@ export class OCPP16ResponseService extends OCPPResponseService { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion chargingStation.powerDivider!-- } - resetConnectorStatus(chargingStation.getConnectorStatus(transactionConnectorId)) + const transactionConnectorStatus = chargingStation.getConnectorStatus(transactionConnectorId) + resetConnectorStatus(transactionConnectorStatus) + if ( + transactionConnectorStatus != null && + (payload.idTagInfo == null || payload.idTagInfo.status === OCPP16AuthorizationStatus.ACCEPTED) + ) { + transactionConnectorStatus.locked = false + } OCPP16ServiceUtils.stopPeriodicMeterValues(chargingStation, transactionConnectorId) const logMsg = `${chargingStation.logPrefix()} ${moduleName}.handleResponseStopTransaction: Transaction with id ${requestPayload.transactionId.toString()} STOPPED on ${ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index 9bbcfb32..61f42c40 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -2708,6 +2708,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { evseId, } as unknown as OCPP20StatusNotificationRequest) + chargingStation.unlockConnector(connectorId) return { status: UnlockStatusEnumType.Unlocked } } catch (error) { logger.error( diff --git a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts index 57895e2c..f217574f 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts @@ -389,6 +389,9 @@ export class OCPP20ResponseService extends OCPPResponseService { const isIdTokenAccepted = payload.idTokenInfo == null || payload.idTokenInfo.status === OCPP20AuthorizationStatusEnumType.Accepted + if (isIdTokenAccepted) { + connectorStatus.locked = true + } if (connectorId != null && isIdTokenAccepted) { sendAndSetConnectorStatus(chargingStation, { connectorId, diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 8a543958..a70fa0a3 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -832,6 +832,7 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId) resetConnectorStatus(connectorStatus) + connectorStatus.locked = false await sendAndSetConnectorStatus(chargingStation, { connectorId, connectorStatus: ConnectorStatusEnum.Available, diff --git a/src/charging-station/ui-server/mcp/MCPToolSchemas.ts b/src/charging-station/ui-server/mcp/MCPToolSchemas.ts index 2eee1257..0b873e63 100644 --- a/src/charging-station/ui-server/mcp/MCPToolSchemas.ts +++ b/src/charging-station/ui-server/mcp/MCPToolSchemas.ts @@ -22,6 +22,11 @@ const broadcastInputSchema = z.object({ hashIds, }) +const connectorInputSchema = z.object({ + connectorId: z.number().int().positive().describe('Target connector ID'), + hashIds, +}) + const emptyInputSchema = z.object({}) const chargingStationOptionsSchema = z.object({ @@ -246,6 +251,13 @@ export const mcpToolSchemas = new Map([ inputSchema: emptyInputSchema, }, ], + [ + ProcedureName.LOCK_CONNECTOR, + { + description: 'Engage the cable retention lock on a connector', + inputSchema: connectorInputSchema, + }, + ], [ ProcedureName.LOG_STATUS_NOTIFICATION, { @@ -411,4 +423,11 @@ export const mcpToolSchemas = new Map([ inputSchema: ocppInputSchema(ProcedureName.TRANSACTION_EVENT), }, ], + [ + ProcedureName.UNLOCK_CONNECTOR, + { + description: 'Release the cable retention lock on a connector', + inputSchema: connectorInputSchema, + }, + ], ]) diff --git a/src/charging-station/ui-server/ui-services/AbstractUIService.ts b/src/charging-station/ui-server/ui-services/AbstractUIService.ts index 6e753d4d..cddb5dcd 100644 --- a/src/charging-station/ui-server/ui-services/AbstractUIService.ts +++ b/src/charging-station/ui-server/ui-services/AbstractUIService.ts @@ -67,6 +67,7 @@ export abstract class AbstractUIService { ], [ProcedureName.GET_CERTIFICATE_STATUS, BroadcastChannelProcedureName.GET_CERTIFICATE_STATUS], [ProcedureName.HEARTBEAT, BroadcastChannelProcedureName.HEARTBEAT], + [ProcedureName.LOCK_CONNECTOR, BroadcastChannelProcedureName.LOCK_CONNECTOR], [ProcedureName.LOG_STATUS_NOTIFICATION, BroadcastChannelProcedureName.LOG_STATUS_NOTIFICATION], [ProcedureName.METER_VALUES, BroadcastChannelProcedureName.METER_VALUES], [ @@ -95,6 +96,7 @@ export abstract class AbstractUIService { [ProcedureName.STOP_CHARGING_STATION, BroadcastChannelProcedureName.STOP_CHARGING_STATION], [ProcedureName.STOP_TRANSACTION, BroadcastChannelProcedureName.STOP_TRANSACTION], [ProcedureName.TRANSACTION_EVENT, BroadcastChannelProcedureName.TRANSACTION_EVENT], + [ProcedureName.UNLOCK_CONNECTOR, BroadcastChannelProcedureName.UNLOCK_CONNECTOR], ]) protected readonly requestHandlers: Map diff --git a/src/types/ConnectorStatus.ts b/src/types/ConnectorStatus.ts index 5243c157..c25c2330 100644 --- a/src/types/ConnectorStatus.ts +++ b/src/types/ConnectorStatus.ts @@ -16,6 +16,7 @@ export interface ConnectorStatus { idTagAuthorized?: boolean idTagLocalAuthorized?: boolean localAuthorizeIdTag?: string + locked?: boolean MeterValues: SampledValueTemplate[] remoteStartId?: number reservation?: Reservation diff --git a/src/types/UIProtocol.ts b/src/types/UIProtocol.ts index 4fc5305b..90f6725e 100644 --- a/src/types/UIProtocol.ts +++ b/src/types/UIProtocol.ts @@ -27,6 +27,7 @@ export enum ProcedureName { HEARTBEAT = 'heartbeat', LIST_CHARGING_STATIONS = 'listChargingStations', LIST_TEMPLATES = 'listTemplates', + LOCK_CONNECTOR = 'lockConnector', LOG_STATUS_NOTIFICATION = 'logStatusNotification', METER_VALUES = 'meterValues', NOTIFY_CUSTOMER_INFORMATION = 'notifyCustomerInformation', @@ -47,6 +48,7 @@ export enum ProcedureName { STOP_SIMULATOR = 'stopSimulator', STOP_TRANSACTION = 'stopTransaction', TRANSACTION_EVENT = 'transactionEvent', + UNLOCK_CONNECTOR = 'unlockConnector', } export enum Protocol { diff --git a/src/types/WorkerBroadcastChannel.ts b/src/types/WorkerBroadcastChannel.ts index e783e77d..008b617d 100644 --- a/src/types/WorkerBroadcastChannel.ts +++ b/src/types/WorkerBroadcastChannel.ts @@ -12,6 +12,7 @@ export enum BroadcastChannelProcedureName { GET_15118_EV_CERTIFICATE = 'get15118EVCertificate', GET_CERTIFICATE_STATUS = 'getCertificateStatus', HEARTBEAT = 'heartbeat', + LOCK_CONNECTOR = 'lockConnector', LOG_STATUS_NOTIFICATION = 'logStatusNotification', METER_VALUES = 'meterValues', NOTIFY_CUSTOMER_INFORMATION = 'notifyCustomerInformation', @@ -28,6 +29,7 @@ export enum BroadcastChannelProcedureName { STOP_CHARGING_STATION = 'stopChargingStation', STOP_TRANSACTION = 'stopTransaction', TRANSACTION_EVENT = 'transactionEvent', + UNLOCK_CONNECTOR = 'unlockConnector', } export type BroadcastChannelRequest = [ diff --git a/tests/charging-station/ChargingStation-Connectors.test.ts b/tests/charging-station/ChargingStation-Connectors.test.ts index 402df3eb..e90c2cc3 100644 --- a/tests/charging-station/ChargingStation-Connectors.test.ts +++ b/tests/charging-station/ChargingStation-Connectors.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import type { ChargingStation } from '../../src/charging-station/index.js' +import { resetConnectorStatus } from '../../src/charging-station/Helpers.js' import { RegistrationStatusEnumType } from '../../src/types/index.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' import { TEST_ONE_HOUR_MS } from './ChargingStationTestConstants.js' @@ -651,4 +652,107 @@ await describe('ChargingStation Connector and EVSE State', async () => { assert.strictEqual(found2?.connectorId, 2) }) }) + + await describe('Connector Lock/Unlock', async () => { + let station: ChargingStation | undefined + + beforeEach(() => { + station = undefined + }) + + afterEach(() => { + standardCleanup() + if (station != null) { + cleanupChargingStation(station) + } + }) + + await it('should set locked=true on lockConnector() for valid connector', () => { + const result = createMockChargingStation({ connectorsCount: 2 }) + station = result.station + + station.lockConnector(1) + + assert.strictEqual(station.getConnectorStatus(1)?.locked, true) + }) + + await it('should set locked=false on unlockConnector() for valid connector', () => { + const result = createMockChargingStation({ connectorsCount: 2 }) + station = result.station + + station.lockConnector(1) + assert.strictEqual(station.getConnectorStatus(1)?.locked, true) + + station.unlockConnector(1) + assert.strictEqual(station.getConnectorStatus(1)?.locked, false) + }) + + await it('should be idempotent on double lockConnector()', () => { + const result = createMockChargingStation({ connectorsCount: 2 }) + station = result.station + + station.lockConnector(1) + station.lockConnector(1) + + assert.strictEqual(station.getConnectorStatus(1)?.locked, true) + }) + + await it('should be idempotent on double unlockConnector()', () => { + const result = createMockChargingStation({ connectorsCount: 2 }) + station = result.station + + station.unlockConnector(1) + station.unlockConnector(1) + + assert.strictEqual(station.getConnectorStatus(1)?.locked, false) + }) + + await it('should reject connector id 0 for lockConnector()', () => { + const result = createMockChargingStation({ connectorsCount: 2 }) + station = result.station + + station.lockConnector(0) + + assert.notStrictEqual(station.getConnectorStatus(0)?.locked, true) + }) + + await it('should reject connector id 0 for unlockConnector()', () => { + const result = createMockChargingStation({ connectorsCount: 2 }) + station = result.station + + station.unlockConnector(0) + + assert.notStrictEqual(station.getConnectorStatus(0)?.locked, false) + }) + + await it('should reject non-existent connector for lockConnector()', () => { + const result = createMockChargingStation({ connectorsCount: 2 }) + station = result.station + + station.lockConnector(999) + + assert.strictEqual(station.getConnectorStatus(999), undefined) + }) + + await it('should reject non-existent connector for unlockConnector()', () => { + const result = createMockChargingStation({ connectorsCount: 2 }) + station = result.station + + station.unlockConnector(999) + + assert.strictEqual(station.getConnectorStatus(999), undefined) + }) + + await it('should not clear locked state on resetConnectorStatus', () => { + const result = createMockChargingStation({ connectorsCount: 2 }) + station = result.station + + station.lockConnector(1) + assert.strictEqual(station.getConnectorStatus(1)?.locked, true) + + resetConnectorStatus(station.getConnectorStatus(1)) + + assert.strictEqual(station.getConnectorStatus(1)?.locked, true) + }) + }) }) diff --git a/tests/charging-station/helpers/StationHelpers.ts b/tests/charging-station/helpers/StationHelpers.ts index b89b46de..5150dbfa 100644 --- a/tests/charging-station/helpers/StationHelpers.ts +++ b/tests/charging-station/helpers/StationHelpers.ts @@ -688,6 +688,20 @@ export function createMockChargingStation ( listenerCount: () => 0, + lockConnector (connectorId: number): void { + if (connectorId === 0) { + return + } + if (!this.hasConnector(connectorId)) { + return + } + const connectorStatus = this.getConnectorStatus(connectorId) + if (connectorStatus == null) { + return + } + connectorStatus.locked = true + }, + logPrefix (): string { return `${this.stationInfo.chargingStationId} |` }, @@ -822,6 +836,21 @@ export function createMockChargingStation ( stopping: false, templateFile, + + unlockConnector (connectorId: number): void { + if (connectorId === 0) { + return + } + if (!this.hasConnector(connectorId)) { + return + } + const connectorStatus = this.getConnectorStatus(connectorId) + if (connectorStatus == null) { + return + } + connectorStatus.locked = false + }, + wsConnection: null as MockWebSocket | null, wsConnectionRetryCount: 0, } diff --git a/ui/web/src/components/charging-stations/CSConnector.vue b/ui/web/src/components/charging-stations/CSConnector.vue index d4f274ce..c1dbddc9 100644 --- a/ui/web/src/components/charging-stations/CSConnector.vue +++ b/ui/web/src/components/charging-stations/CSConnector.vue @@ -6,6 +6,9 @@ {{ connector.status ?? 'Ø' }} + + {{ connector.locked === true ? 'Yes' : 'No' }} + {{ connector.transactionStarted === true ? `Yes (${connector.transactionId})` : 'No' }} @@ -46,6 +49,18 @@ + + @@ -93,6 +108,28 @@ const stopTransaction = (): void => { console.error('Error at stopping transaction:', error) }) } +const lockConnector = (): void => { + uiClient + .lockConnector(props.hashId, props.connectorId) + .then(() => { + return $toast.success('Connector successfully locked') + }) + .catch((error: Error) => { + $toast.error('Error at locking connector') + console.error('Error at locking connector:', error) + }) +} +const unlockConnector = (): void => { + uiClient + .unlockConnector(props.hashId, props.connectorId) + .then(() => { + return $toast.success('Connector successfully unlocked') + }) + .catch((error: Error) => { + $toast.error('Error at unlocking connector') + console.error('Error at unlocking connector:', error) + }) +} const startAutomaticTransactionGenerator = (): void => { uiClient .startAutomaticTransactionGenerator(props.hashId, props.connectorId) diff --git a/ui/web/src/components/charging-stations/CSData.vue b/ui/web/src/components/charging-stations/CSData.vue index 216e227d..da44b08d 100644 --- a/ui/web/src/components/charging-stations/CSData.vue +++ b/ui/web/src/components/charging-stations/CSData.vue @@ -86,6 +86,12 @@ > Status + + Locked + { + return this.sendRequest(ProcedureName.LOCK_CONNECTOR, { + connectorId, + hashIds: [hashId], + }) + } + public async openConnection (hashId: string): Promise { return this.sendRequest(ProcedureName.OPEN_CONNECTION, { hashIds: [hashId], @@ -218,6 +225,13 @@ export class UIClient { }) } + public async unlockConnector (hashId: string, connectorId: number): Promise { + return this.sendRequest(ProcedureName.UNLOCK_CONNECTOR, { + connectorId, + hashIds: [hashId], + }) + } + public unregisterWSEventListener( event: K, listener: (event: WebSocketEventMap[K]) => void, diff --git a/ui/web/src/types/ChargingStationType.ts b/ui/web/src/types/ChargingStationType.ts index 70809450..71d230b9 100644 --- a/ui/web/src/types/ChargingStationType.ts +++ b/ui/web/src/types/ChargingStationType.ts @@ -261,6 +261,7 @@ export interface ConnectorStatus extends JsonObject { idTagAuthorized?: boolean idTagLocalAuthorized?: boolean localAuthorizeIdTag?: string + locked?: boolean status?: ChargePointStatus transactionEnergyActiveImportRegisterValue?: number // In Wh /** diff --git a/ui/web/src/types/UIProtocol.ts b/ui/web/src/types/UIProtocol.ts index 4539529e..539d436e 100644 --- a/ui/web/src/types/UIProtocol.ts +++ b/ui/web/src/types/UIProtocol.ts @@ -19,6 +19,7 @@ export enum ProcedureName { GET_CERTIFICATE_STATUS = 'getCertificateStatus', LIST_CHARGING_STATIONS = 'listChargingStations', LIST_TEMPLATES = 'listTemplates', + LOCK_CONNECTOR = 'lockConnector', LOG_STATUS_NOTIFICATION = 'logStatusNotification', NOTIFY_CUSTOMER_INFORMATION = 'notifyCustomerInformation', NOTIFY_REPORT = 'notifyReport', @@ -36,6 +37,7 @@ export enum ProcedureName { STOP_SIMULATOR = 'stopSimulator', STOP_TRANSACTION = 'stopTransaction', TRANSACTION_EVENT = 'transactionEvent', + UNLOCK_CONNECTOR = 'unlockConnector', } export enum Protocol { diff --git a/ui/web/tests/unit/CSConnector.test.ts b/ui/web/tests/unit/CSConnector.test.ts index 3b204a59..ba327d3f 100644 --- a/ui/web/tests/unit/CSConnector.test.ts +++ b/ui/web/tests/unit/CSConnector.test.ts @@ -83,7 +83,7 @@ describe('CSConnector', () => { it('should display No when transaction not started', () => { const wrapper = mountCSConnector() const cells = wrapper.findAll('td') - expect(cells[2].text()).toBe('No') + expect(cells[3].text()).toBe('No') }) it('should display Yes with transaction ID when transaction started', () => { @@ -91,25 +91,25 @@ describe('CSConnector', () => { connector: createConnectorStatus({ transactionId: 12345, transactionStarted: true }), }) const cells = wrapper.findAll('td') - expect(cells[2].text()).toBe('Yes (12345)') + expect(cells[3].text()).toBe('Yes (12345)') }) it('should display ATG started as Yes when active', () => { const wrapper = mountCSConnector({ atgStatus: { start: true } }) const cells = wrapper.findAll('td') - expect(cells[3].text()).toBe('Yes') + expect(cells[4].text()).toBe('Yes') }) it('should display ATG started as No when not active', () => { const wrapper = mountCSConnector({ atgStatus: { start: false } }) const cells = wrapper.findAll('td') - expect(cells[3].text()).toBe('No') + expect(cells[4].text()).toBe('No') }) it('should display ATG started as No when atgStatus undefined', () => { const wrapper = mountCSConnector() const cells = wrapper.findAll('td') - expect(cells[3].text()).toBe('No') + expect(cells[4].text()).toBe('No') }) }) @@ -201,4 +201,100 @@ describe('CSConnector', () => { ) }) }) + + describe('lock/unlock actions', () => { + it('should display Locked column as No when not locked', () => { + const wrapper = mountCSConnector() + const cells = wrapper.findAll('td') + expect(cells[2].text()).toBe('No') + }) + + it('should display Locked column as Yes when locked', () => { + const wrapper = mountCSConnector({ + connector: createConnectorStatus({ locked: true }), + }) + const cells = wrapper.findAll('td') + expect(cells[2].text()).toBe('Yes') + }) + + it('should show Lock button when connector is not locked', () => { + const wrapper = mountCSConnector() + const buttons = wrapper.findAll('button') + const lockBtn = buttons.find(b => b.text() === 'Lock') + expect(lockBtn).toBeDefined() + expect(buttons.find(b => b.text() === 'Unlock')).toBeUndefined() + }) + + it('should show Unlock button when connector is locked', () => { + const wrapper = mountCSConnector({ + connector: createConnectorStatus({ locked: true }), + }) + const buttons = wrapper.findAll('button') + const unlockBtn = buttons.find(b => b.text() === 'Unlock') + expect(unlockBtn).toBeDefined() + expect(buttons.find(b => b.text() === 'Lock')).toBeUndefined() + }) + + it('should call lockConnector with correct params', async () => { + const wrapper = mountCSConnector() + const buttons = wrapper.findAll('button') + const lockBtn = buttons.find(b => b.text() === 'Lock') + await lockBtn?.trigger('click') + await flushPromises() + expect(mockClient.lockConnector).toHaveBeenCalledWith(TEST_HASH_ID, 1) + }) + + it('should call unlockConnector with correct params', async () => { + const wrapper = mountCSConnector({ + connector: createConnectorStatus({ locked: true }), + }) + const buttons = wrapper.findAll('button') + const unlockBtn = buttons.find(b => b.text() === 'Unlock') + await unlockBtn?.trigger('click') + await flushPromises() + expect(mockClient.unlockConnector).toHaveBeenCalledWith(TEST_HASH_ID, 1) + }) + + it('should show success toast after locking connector', async () => { + const wrapper = mountCSConnector() + const buttons = wrapper.findAll('button') + const lockBtn = buttons.find(b => b.text() === 'Lock') + await lockBtn?.trigger('click') + await flushPromises() + expect(toastMock.success).toHaveBeenCalledWith('Connector successfully locked') + }) + + it('should show error toast on lock failure', async () => { + mockClient.lockConnector.mockRejectedValueOnce(new Error('fail')) + const wrapper = mountCSConnector() + const buttons = wrapper.findAll('button') + const lockBtn = buttons.find(b => b.text() === 'Lock') + await lockBtn?.trigger('click') + await flushPromises() + expect(toastMock.error).toHaveBeenCalledWith('Error at locking connector') + }) + + it('should show success toast after unlocking connector', async () => { + const wrapper = mountCSConnector({ + connector: createConnectorStatus({ locked: true }), + }) + const buttons = wrapper.findAll('button') + const unlockBtn = buttons.find(b => b.text() === 'Unlock') + await unlockBtn?.trigger('click') + await flushPromises() + expect(toastMock.success).toHaveBeenCalledWith('Connector successfully unlocked') + }) + + it('should show error toast on unlock failure', async () => { + mockClient.unlockConnector.mockRejectedValueOnce(new Error('fail')) + const wrapper = mountCSConnector({ + connector: createConnectorStatus({ locked: true }), + }) + const buttons = wrapper.findAll('button') + const unlockBtn = buttons.find(b => b.text() === 'Unlock') + await unlockBtn?.trigger('click') + await flushPromises() + expect(toastMock.error).toHaveBeenCalledWith('Error at unlocking connector') + }) + }) }) diff --git a/ui/web/tests/unit/UIClient.test.ts b/ui/web/tests/unit/UIClient.test.ts index 41ec2b96..5c80ec40 100644 --- a/ui/web/tests/unit/UIClient.test.ts +++ b/ui/web/tests/unit/UIClient.test.ts @@ -627,4 +627,33 @@ describe('UIClient', () => { }) }) }) + + describe('connector lock operations', () => { + let client: UIClient + let sendRequestSpy: ReturnType + + beforeEach(() => { + client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private method for testing + sendRequestSpy = vi.spyOn(client, 'sendRequest').mockResolvedValue({ + status: ResponseStatus.SUCCESS, + }) + }) + + it('should send LOCK_CONNECTOR with hashIds and connectorId', async () => { + await client.lockConnector(TEST_HASH_ID, 1) + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.LOCK_CONNECTOR, { + connectorId: 1, + hashIds: [TEST_HASH_ID], + }) + }) + + it('should send UNLOCK_CONNECTOR with hashIds and connectorId', async () => { + await client.unlockConnector(TEST_HASH_ID, 2) + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.UNLOCK_CONNECTOR, { + connectorId: 2, + hashIds: [TEST_HASH_ID], + }) + }) + }) }) diff --git a/ui/web/tests/unit/helpers.ts b/ui/web/tests/unit/helpers.ts index 0d2be6d9..4fb255ca 100644 --- a/ui/web/tests/unit/helpers.ts +++ b/ui/web/tests/unit/helpers.ts @@ -19,6 +19,7 @@ export interface MockUIClient { deleteChargingStation: ReturnType listChargingStations: ReturnType listTemplates: ReturnType + lockConnector: ReturnType openConnection: ReturnType registerWSEventListener: ReturnType setConfiguration: ReturnType @@ -32,6 +33,7 @@ export interface MockUIClient { stopChargingStation: ReturnType stopSimulator: ReturnType stopTransaction: ReturnType + unlockConnector: ReturnType unregisterWSEventListener: ReturnType } @@ -116,6 +118,7 @@ export function createMockUIClient (): MockUIClient { deleteChargingStation: vi.fn().mockResolvedValue(successResponse), listChargingStations: vi.fn().mockResolvedValue({ ...successResponse, chargingStations: [] }), listTemplates: vi.fn().mockResolvedValue({ ...successResponse, templates: [] }), + lockConnector: vi.fn().mockResolvedValue(successResponse), openConnection: vi.fn().mockResolvedValue(successResponse), registerWSEventListener: vi.fn(), setConfiguration: vi.fn(), @@ -131,6 +134,7 @@ export function createMockUIClient (): MockUIClient { stopChargingStation: vi.fn().mockResolvedValue(successResponse), stopSimulator: vi.fn().mockResolvedValue(successResponse), stopTransaction: vi.fn().mockResolvedValue(successResponse), + unlockConnector: vi.fn().mockResolvedValue(successResponse), unregisterWSEventListener: vi.fn(), } }