]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat: add connector cable retention lock/unlock simulation (#1754)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 25 Mar 2026 18:20:36 +0000 (19:20 +0100)
committerGitHub <noreply@github.com>
Wed, 25 Mar 2026 18:20:36 +0000 (19:20 +0100)
* 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>
23 files changed:
README.md
src/charging-station/ChargingStation.ts
src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/1.6/OCPP16ResponseService.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20ResponseService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ui-server/mcp/MCPToolSchemas.ts
src/charging-station/ui-server/ui-services/AbstractUIService.ts
src/types/ConnectorStatus.ts
src/types/UIProtocol.ts
src/types/WorkerBroadcastChannel.ts
tests/charging-station/ChargingStation-Connectors.test.ts
tests/charging-station/helpers/StationHelpers.ts
ui/web/src/components/charging-stations/CSConnector.vue
ui/web/src/components/charging-stations/CSData.vue
ui/web/src/composables/UIClient.ts
ui/web/src/types/ChargingStationType.ts
ui/web/src/types/UIProtocol.ts
ui/web/tests/unit/CSConnector.test.ts
ui/web/tests/unit/UIClient.test.ts
ui/web/tests/unit/helpers.ts

index a0ae13af99287286238a7cbe04143b09fbb74dbd..6cd19047c42cd5dfcc727030c93b7e1a1f7c2bc2 100644 (file)
--- 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:  
index 605f77fcd40f7abef1cefba8c548b6b9e5becad9..33d201558d0b313965e6ba7735f80e6007d77ae1 100644 (file)
@@ -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)
   }
index 2b37a86f4216bfe5d27c26d73aff465b5f320a0b..74323b597a8fdbeb4477d6871e0da0bd5879b920 100644 (file)
@@ -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
index 4021c0c5da8fd87037c20e848600574fd24d6e09..49d1fe024c2d9528ca194409ab9a97835d5124bd 100644 (file)
@@ -1589,6 +1589,7 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
       connectorId,
       status: OCPP16ChargePointStatus.Available,
     } as OCPP16StatusNotificationRequest)
+    chargingStation.unlockConnector(connectorId)
     return OCPP16Constants.OCPP_RESPONSE_UNLOCKED
   }
 
index 827a85d258d5abdba430b0afd7722005329b2309..826c8f81eb573c9457f9934d0d438d8ebdd91183 100644 (file)
@@ -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
index 9bbcfb32c36bf5e0825379a232374f9aa0a5ce51..61f42c404624ae18c542af1e5be78f423462bac8 100644 (file)
@@ -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(
index 57895e2cb17460d770b4a7ca5811e819fde72d13..f217574f91bd0a85b54a0e5df185c6ddada40b66 100644 (file)
@@ -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,
index 8a543958833b1300e3299b853916e499232e2f8e..a70fa0a368c85859e5c877c87139fc28815520ba 100644 (file)
@@ -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,
index 2eee125773b819107e124a05399b4cc83e45ace7..0b873e630faf2c35dde818b796ac403e3006b1ed 100644 (file)
@@ -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<ProcedureName, MCPToolSchema>([
       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<ProcedureName, MCPToolSchema>([
       inputSchema: ocppInputSchema(ProcedureName.TRANSACTION_EVENT),
     },
   ],
+  [
+    ProcedureName.UNLOCK_CONNECTOR,
+    {
+      description: 'Release the cable retention lock on a connector',
+      inputSchema: connectorInputSchema,
+    },
+  ],
 ])
index 6e753d4d199cf8c811421cee6bfe0f43c4636b8b..cddb5dcdd80ddffdea7a86b702c9d105c329c175 100644 (file)
@@ -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<ProcedureName, ProtocolRequestHandler>
index 5243c157c4858719b78cc8e76968d85e16efa360..c25c23301fc53ec3c235b277323f5b94bde85c7e 100644 (file)
@@ -16,6 +16,7 @@ export interface ConnectorStatus {
   idTagAuthorized?: boolean
   idTagLocalAuthorized?: boolean
   localAuthorizeIdTag?: string
+  locked?: boolean
   MeterValues: SampledValueTemplate[]
   remoteStartId?: number
   reservation?: Reservation
index 4fc5305b85ba2c22cfaf384045f946e49cfa4c1f..90f6725ee47f11dc34a559fafcb62a64656da740 100644 (file)
@@ -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 {
index e783e77d3d92963c9703804197f45b23b99e2b3b..008b617d4fd9a96f051e919116d9ffd8e4d813ce 100644 (file)
@@ -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 = [
index 402df3eb27ee05be36b6cee5cf4826b3c14b430b..e90c2cc36e8fb4c9d5c1e787095b20d54d6e6ab3 100644 (file)
@@ -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)
+    })
+  })
 })
index b89b46de201d45b5c6640832c0f42a7c86ad5ac0..5150dbfa63f67887d4158cf658bae9a88b6dd751 100644 (file)
@@ -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,
   }
index d4f274ce69fabdf245fb677573ba0a344df67530..c1dbddc973f6ab25d2fa276ebdde16ac8a465296 100644 (file)
@@ -6,6 +6,9 @@
     <td class="connectors-table__column">
       {{ connector.status ?? 'Ø' }}
     </td>
+    <td class="connectors-table__column">
+      {{ connector.locked === true ? 'Yes' : 'No' }}
+    </td>
     <td class="connectors-table__column">
       {{ connector.transactionStarted === true ? `Yes (${connector.transactionId})` : 'No' }}
     </td>
       <Button @click="stopAutomaticTransactionGenerator()">
         Stop ATG
       </Button>
+      <Button
+        v-if="connector.locked !== true"
+        @click="lockConnector()"
+      >
+        Lock
+      </Button>
+      <Button
+        v-else
+        @click="unlockConnector()"
+      >
+        Unlock
+      </Button>
     </td>
   </tr>
 </template>
@@ -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)
index 216e227dd5b49ceafd6af0d8680e7872cac6308e..da44b08d70cfe440b86de83cf64e49fd69893262 100644 (file)
             >
               Status
             </th>
+            <th
+              class="connectors-table__column"
+              scope="col"
+            >
+              Locked
+            </th>
             <th
               class="connectors-table__column"
               scope="col"
index fa2c802f966d48e306deeac54fd71867c6b04823..eabf49f0e1a7b2a709e58534463a66bdfe86d904 100644 (file)
@@ -90,6 +90,13 @@ export class UIClient {
     return this.sendRequest(ProcedureName.LIST_TEMPLATES, {})
   }
 
+  public async lockConnector (hashId: string, connectorId: number): Promise<ResponsePayload> {
+    return this.sendRequest(ProcedureName.LOCK_CONNECTOR, {
+      connectorId,
+      hashIds: [hashId],
+    })
+  }
+
   public async openConnection (hashId: string): Promise<ResponsePayload> {
     return this.sendRequest(ProcedureName.OPEN_CONNECTION, {
       hashIds: [hashId],
@@ -218,6 +225,13 @@ export class UIClient {
     })
   }
 
+  public async unlockConnector (hashId: string, connectorId: number): Promise<ResponsePayload> {
+    return this.sendRequest(ProcedureName.UNLOCK_CONNECTOR, {
+      connectorId,
+      hashIds: [hashId],
+    })
+  }
+
   public unregisterWSEventListener<K extends keyof WebSocketEventMap>(
     event: K,
     listener: (event: WebSocketEventMap[K]) => void,
index 70809450408c80e2443a28ad543feb9485819c29..71d230b98efa48d6771f3944cd9c4bdb7fe2b559 100644 (file)
@@ -261,6 +261,7 @@ export interface ConnectorStatus extends JsonObject {
   idTagAuthorized?: boolean
   idTagLocalAuthorized?: boolean
   localAuthorizeIdTag?: string
+  locked?: boolean
   status?: ChargePointStatus
   transactionEnergyActiveImportRegisterValue?: number // In Wh
   /**
index 4539529ebecd3201364c8f7ed76aef5c611e16df..539d436ecbd83c24883545da7ad47d8b734743e6 100644 (file)
@@ -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 {
index 3b204a599f7c44ee31bcd31fc60ee75ae254f5f0..ba327d3f6bc9f9900436d0242f298a211e74388b 100644 (file)
@@ -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')
+    })
+  })
 })
index 41ec2b96a97f0bb4515384d0cc10fe2064d61acc..5c80ec4096f091ae8106875f45a07808100a4b27 100644 (file)
@@ -627,4 +627,33 @@ describe('UIClient', () => {
       })
     })
   })
+
+  describe('connector lock operations', () => {
+    let client: UIClient
+    let sendRequestSpy: ReturnType<typeof vi.spyOn>
+
+    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],
+      })
+    })
+  })
 })
index 0d2be6d9f425e8355c8bbb95fb916bc9d5d3d7f2..4fb255caf31c6518c34c4aa53d5b6d87b6f3f9a6 100644 (file)
@@ -19,6 +19,7 @@ export interface MockUIClient {
   deleteChargingStation: ReturnType<typeof vi.fn>
   listChargingStations: ReturnType<typeof vi.fn>
   listTemplates: ReturnType<typeof vi.fn>
+  lockConnector: ReturnType<typeof vi.fn>
   openConnection: ReturnType<typeof vi.fn>
   registerWSEventListener: ReturnType<typeof vi.fn>
   setConfiguration: ReturnType<typeof vi.fn>
@@ -32,6 +33,7 @@ export interface MockUIClient {
   stopChargingStation: ReturnType<typeof vi.fn>
   stopSimulator: ReturnType<typeof vi.fn>
   stopTransaction: ReturnType<typeof vi.fn>
+  unlockConnector: ReturnType<typeof vi.fn>
   unregisterWSEventListener: ReturnType<typeof vi.fn>
 }
 
@@ -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(),
   }
 }