]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ui): allow changing status of individual connectors (#1834)
authorgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Thu, 7 May 2026 21:56:02 +0000 (23:56 +0200)
committerGitHub <noreply@github.com>
Thu, 7 May 2026 21:56:02 +0000 (23:56 +0200)
* feat(ui/web): allow changing status of individual connectors

Add a 'Set Status' action to the connector UI in both modern and classic
skins. Users can simulate OCPP connector statuses (e.g. Faulted, Unavailable)
directly from the dashboard.

- UIClient.setConnectorStatus sends STATUS_NOTIFICATION via existing ProcedureName
- useConnectorActions exposes setConnectorStatus with pending.setStatus guard
- Modern skin: SetConnectorStatusDialog presents a status picker in a modal
- Classic skin: inline <select> triggers status change on change event
- Test helpers and composable tests updated accordingly

* fix: correct OCPP version handling for connector status changes

- Add OCPP20ConnectorStatusEnumType enum to ui-common
- Fix UIClient.setConnectorStatus to send version-aware payload:
  connectorStatus field for OCPP 2.0.x, status field for OCPP 1.6
- Update useConnectorActions to accept ocppVersion and pass it through;
  widen status parameter type to union of 1.6 and 2.0.x enums;
  accept optional onSuccess callback per action invocation
- Fix SetConnectorStatusDialog to close only after action resolves
  (pass close as onSuccess instead of calling it immediately)
- Show version-appropriate status options in SetConnectorStatusDialog
- Forward ocppVersion and onRefresh from ConnectorRow to dialog
- Propagate need-refresh event through ConnectorRow → StationCard → ModernLayout
- Add tests for new OCPP 2.0.x paths and dialog behaviour

* [autofix.ci] apply automated fixes

* feat(ui): complete connector status change with error simulation and local state update

- Fix classic skin to show version-appropriate status options (OCPP 1.6/2.0.x)
- Pass ocppVersion to useConnectorActions in classic skin
- Replace passthrough STATUS_NOTIFICATION handler with sendAndSetConnectorStatus
  to update in-memory connector state and emit connectorStatusChanged event
- Add OCPP 1.6 errorCode support (issue requests error simulation capability)
- Add OCPP16ChargePointErrorCode enum to ui-common
- Add error code selector in both skins (OCPP 1.6 only, hidden for 2.0.x)
- Remove unnecessary re-exports from useConnectorActions composable
- Update tests for new errorCode parameter and passthrough behavior change

Resolves the outstanding HIGH findings from automated review.

* fix(ui): polish connector status — reactivity consistency, JSDoc, tests, init from current status

- Wrap props in computed() in SetConnectorStatusDialog for consistency
- Fix JSDoc on mountDialog test helper to satisfy jsdoc/require-jsdoc
- Initialize selectedStatus from connector.status for OCPP 2.0.x too
- Add 5 unit tests for classic skin CSConnector status change behavior

* refactor(ui): remove redundant onRefresh from connector actions

The server already pushes a REFRESH notification via WebSocket when
connector state changes (connectorStatusChanged → buildUpdatedMessage →
workerEventUpdated → scheduleClientNotification → REFRESH broadcast).

The onRefresh callback in useConnectorActions duplicated this by manually
calling getChargingStations() after each action. Remove it along with
the need-refresh event bubbling chain in the modern skin.

The useAsyncAction onRefresh mechanism remains available for composables
where the server does NOT push updates (e.g., useStationActions).

* [autofix.ci] apply automated fixes

* refactor(ui-common): extract buildStatusNotificationPayload shared builder

Factor version-aware StatusNotification payload construction into
ui-common alongside existing buildAuthorize/Start/StopTransactionPayload
builders. Both CLI and Web UI now use the shared builder, eliminating
duplicated OCPP 1.6 vs 2.0.x branching logic.

* [autofix.ci] apply automated fixes

* fix(ui-common): keep buildStatusNotificationPayload strongly typed

Remove string fallback from status/errorCode parameters — the builder
accepts only the OCPP enum types. The CLI casts user input at the call
site, keeping the shared API type-safe.

* [autofix.ci] apply automated fixes

* fix(ui-common): remove invalid Occupied from OCPP16ChargePointStatus enum

Occupied is an OCPP 2.0.x-only connector status. It was erroneously
included in the OCPP 1.6 enum, causing the UI dropdown to offer an
invalid status option for OCPP 1.6 stations. Tests referencing it are
updated to use OCPP20ConnectorStatusEnumType.OCCUPIED or a valid 1.6
status as appropriate.

* fix(ui-common): make ChargePointStatus a union of OCPP 1.6 and 2.0.x enums

ChargePointStatus was aliased to OCPP16ChargePointStatus only, which
made ConnectorStatus.status unable to represent OCPP 2.0.x values like
Occupied. Now it is OCPP16ChargePointStatus | OCPP20ConnectorStatusEnumType,
matching the src/ canonical ConnectorStatusEnum pattern.

* refactor(ui-common): use ChargePointStatus type alias instead of inline union

Replace all occurrences of the verbose
'OCPP16ChargePointStatus | OCPP20ConnectorStatusEnumType' inline union
with the existing ChargePointStatus type alias across ui-common, web UI,
and CLI.

* [autofix.ci] apply automated fixes

* fix: add connectorId guard to handleStatusNotification for consistency

Other broadcast channel handlers (handleMeterValues, UNLOCK_CONNECTOR,
LOCK_CONNECTOR) throw BaseError when connectorId is missing. Without
this guard, a malformed request would silently succeed.

* fix(ui): address review feedback — clickable status pill, stale state sync, error-code apply

Modern skin:
- Replace 'Set Status' button with clickable status pill (edit icon on
  hover, tooltip with status, aria-haspopup=dialog). More compact UX per
  reviewer request (DerGenaue).
- Add .modern-pill--editable CSS with hover border, focus ring, and
  fade-in edit icon.

Classic skin:
- Add watch on props.connector.status to sync selectedStatus ref when
  server pushes new state (fixes stale dropdown after external changes).
- Add @change handler on error-code select so changing errorCode alone
  also triggers a StatusNotification (previously only status change did).

Addresses review feedback from hyperspace-insights, copilot, and
DerGenaue.

* fix: persist errorCode on ConnectorStatus, remove Partial<> cast, add tooltip

Backend:
- Add errorCode field to ConnectorStatus (src/ and ui-common)
- Persist errorCode in sendAndSetConnectorStatus alongside status
- Move errorCode defaulting from buildStatusNotificationRequest to
  OCPP16RequestService.buildRequestPayload via spread default pattern:
  { errorCode: NO_ERROR, ...commandParams }
- Remove Partial<> cast and ?? fallback from the builder — it now
  simply passes through commandParams.errorCode (always defined by
  the time it reaches the builder)

UI:
- Status pill tooltip shows errorCode when present and not NoError
  (e.g., 'Faulted (ConnectorLockFailure)'), per DerGenaue's request
- ConnectorStatus.errorCode propagates automatically through existing
  buildUpdatedMessage → spread serialization chain

* fix(ui): address DerGenaue feedback — always-visible icon, preserve pill height

- Edit icon always visible (no hover-only opacity transition)
- Smaller icon (8px) + reduced gap (3px) to preserve original pill height
- Simpler pencil SVG (single path, thicker stroke for clarity at small size)
- Removed opacity animation rules

* fix(ui): editable status pill design (#1838)

* fix: validate status field in handleStatusNotification

Prevents connectorStatus.status corruption when ocppStrictCompliance is
disabled and the payload arrives without a status value.

---------

Co-authored-by: OpenCode Agent <agent@opencode.ai>
Co-authored-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
22 files changed:
src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/ocpp/1.6/OCPP16RequestService.ts
src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts
src/charging-station/ocpp/OCPPConnectorStatusOperations.ts
src/types/ConnectorStatus.ts
tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts
ui/cli/src/commands/ocpp.ts
ui/cli/tests/format.test.ts
ui/common/src/types/ChargingStationType.ts
ui/common/src/utils/payloadBuilders.ts
ui/web/src/core/UIClient.ts
ui/web/src/shared/composables/useConnectorActions.ts
ui/web/src/skins/classic/components/charging-stations/CSConnector.vue
ui/web/src/skins/modern/components/ConnectorRow.vue
ui/web/src/skins/modern/components/dialogs/SetConnectorStatusDialog.vue [new file with mode: 0644]
ui/web/src/skins/modern/modern.css
ui/web/tests/unit/helpers.ts
ui/web/tests/unit/shared/composables/stationStatus.test.ts
ui/web/tests/unit/shared/composables/useConnectorActions.test.ts
ui/web/tests/unit/skins/classic/CSConnector.test.ts
ui/web/tests/unit/skins/modern/ConnectorRow.test.ts
ui/web/tests/unit/skins/modern/Dialogs.test.ts

index 297ed559504bbd68b3dcb115883e447866243ae7..029ed1185facbd9b4dcc39f8b128ad8d83b525d8 100644 (file)
@@ -35,6 +35,7 @@ import {
   ResponseStatus,
   StandardParametersKey,
   type StartTransactionResponse,
+  type StatusNotificationRequest,
   type StopTransactionRequest,
   type StopTransactionResponse,
 } from '../../types/index.js'
@@ -47,7 +48,7 @@ import {
   logger,
 } from '../../utils/index.js'
 import { getConfigurationKey } from '../ConfigurationKeyUtils.js'
-import { buildMeterValue, OCPP20ServiceUtils } from '../ocpp/index.js'
+import { buildMeterValue, OCPP20ServiceUtils, sendAndSetConnectorStatus } from '../ocpp/index.js'
 import { WorkerBroadcastChannel } from './WorkerBroadcastChannel.js'
 
 const moduleName = 'ChargingStationWorkerBroadcastChannel'
@@ -218,10 +219,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
         BroadcastChannelProcedureName.START_TRANSACTION,
         this.passthrough(RequestCommand.START_TRANSACTION),
       ],
-      [
-        BroadcastChannelProcedureName.STATUS_NOTIFICATION,
-        this.passthrough(RequestCommand.STATUS_NOTIFICATION),
-      ],
+      [BroadcastChannelProcedureName.STATUS_NOTIFICATION, this.handleStatusNotification.bind(this)],
       [
         BroadcastChannelProcedureName.STOP_AUTOMATIC_TRANSACTION_GENERATOR,
         (requestPayload?: BroadcastChannelRequestPayload) => {
@@ -444,6 +442,26 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
     )
   }
 
+  private async handleStatusNotification (
+    requestPayload?: BroadcastChannelRequestPayload
+  ): Promise<void> {
+    if (requestPayload?.connectorId == null) {
+      throw new BaseError(
+        `${this.chargingStation.logPrefix()} ${moduleName}.handleStatusNotification: 'connectorId' field is required`
+      )
+    }
+    const payload = requestPayload as Record<string, unknown>
+    if (payload.connectorStatus == null && payload.status == null) {
+      throw new BaseError(
+        `${this.chargingStation.logPrefix()} ${moduleName}.handleStatusNotification: 'connectorStatus' or 'status' field is required`
+      )
+    }
+    await sendAndSetConnectorStatus(
+      this.chargingStation,
+      requestPayload as unknown as StatusNotificationRequest
+    )
+  }
+
   private async handleStopTransaction (
     requestPayload?: BroadcastChannelRequestPayload
   ): Promise<StopTransactionResponse> {
index 2c65bbd58db5c19ab064eb57535db21549dc4143..01662f6e0225dbf9cb0202b4b383d28820b8ea0c 100644 (file)
@@ -5,6 +5,7 @@ import type { OCPPResponseService } from '../OCPPResponseService.js'
 
 import { OCPPError } from '../../../exception/index.js'
 import {
+  ChargePointErrorCode,
   ErrorType,
   type JsonObject,
   type JsonType,
@@ -210,9 +211,10 @@ export class OCPP16RequestService extends OCPPRequestService {
           ...commandParams,
         } as unknown as Request
       case OCPP16RequestCommand.STATUS_NOTIFICATION:
-        return OCPP16ServiceUtils.buildStatusNotificationRequest(
-          commandParams as unknown as OCPP16StatusNotificationRequest
-        ) as unknown as Request
+        return OCPP16ServiceUtils.buildStatusNotificationRequest({
+          errorCode: ChargePointErrorCode.NO_ERROR,
+          ...commandParams,
+        } as unknown as OCPP16StatusNotificationRequest) as unknown as Request
       case OCPP16RequestCommand.STOP_TRANSACTION:
         ;(chargingStation.stationInfo?.transactionDataMeterValues === true ||
           OCPP16ServiceUtils.isSigningEnabled(chargingStation)) &&
index f5d24fc2396050dd59c480e58f1ab3ffc093bf7a..c0e2068d4c3592296b12a4d8937ad79b405a270c 100644 (file)
@@ -18,7 +18,6 @@ import {
 } from '../../../charging-station/index.js'
 import { BaseError } from '../../../exception/index.js'
 import {
-  ChargePointErrorCode,
   type ConfigurationKey,
   type GenericResponse,
   type MeterValuesRequest,
@@ -131,7 +130,7 @@ export class OCPP16ServiceUtils {
   ): OCPP16StatusNotificationRequest {
     return {
       connectorId: commandParams.connectorId,
-      errorCode: ChargePointErrorCode.NO_ERROR,
+      errorCode: commandParams.errorCode,
       status: commandParams.status,
     } satisfies OCPP16StatusNotificationRequest
   }
index c221a63fa09ccaa989745111f87108b5f1b1e4f9..2ad010ebdd5e6f10b9819acdf644e7a838576fda 100644 (file)
@@ -5,6 +5,7 @@ import {
   type ConnectorStatus,
   ConnectorStatusEnum,
   ErrorType,
+  type OCPP16ChargePointErrorCode,
   OCPPVersion,
   RequestCommand,
   type StatusNotificationRequest,
@@ -30,6 +31,7 @@ export const sendAndSetConnectorStatus = async (
   const params = commandParams as Record<string, unknown>
   const connectorId = params.connectorId as number
   const status = (params.connectorStatus ?? params.status) as ConnectorStatusEnum
+  const errorCode = params.errorCode as OCPP16ChargePointErrorCode | undefined
   const connectorStatus = chargingStation.getConnectorStatus(connectorId)
   if (connectorStatus == null) {
     return
@@ -42,6 +44,7 @@ export const sendAndSetConnectorStatus = async (
     >(chargingStation, RequestCommand.STATUS_NOTIFICATION, commandParams)
   }
   connectorStatus.status = status
+  connectorStatus.errorCode = errorCode
   chargingStation.emitChargingStationEvent(ChargingStationEvents.connectorStatusChanged, {
     connectorId,
     ...connectorStatus,
index fab168e8f6bdfa29573288cd6e60d49d0c096117..de1e62aa5b622291ad9d2cc211bef0fb4f516692 100644 (file)
@@ -1,4 +1,5 @@
 import type { SampledValueTemplate } from './MeasurandPerPhaseSampledValueTemplates.js'
+import type { OCPP16ChargePointErrorCode } from './ocpp/1.6/ChargePointErrorCode.js'
 import type { OCPP20TransactionEventRequest } from './ocpp/2.0/Transaction.js'
 import type { ChargingProfile } from './ocpp/ChargingProfile.js'
 import type { ConnectorEnumType } from './ocpp/ConnectorEnumType.js'
@@ -19,6 +20,7 @@ export interface ConnectorStatus {
   bootStatus?: ConnectorStatusEnum
   chargingProfiles?: ChargingProfile[]
   energyActiveImportRegisterValue?: number // In Wh
+  errorCode?: OCPP16ChargePointErrorCode
   idTagAuthorized?: boolean
   idTagLocalAuthorized?: boolean
   localAuthorizeIdTag?: string
index 5178e5d16c97d75bd09f8db5f4bbaf3a5f971987..dbd9bc57cbbe845831862cd8a3745eeb92b6734c 100644 (file)
@@ -574,7 +574,7 @@ await describe('OCPP16ServiceUtils — pure functions', async () => {
       assert.strictEqual(result.status, OCPP16ChargePointStatus.Charging)
     })
 
-    await it('should always set errorCode to NO_ERROR regardless of input errorCode', () => {
+    await it('should pass through provided errorCode', () => {
       const input: OCPP16StatusNotificationRequest = {
         connectorId: 1,
         errorCode: ChargePointErrorCode.CONNECTOR_LOCK_FAILURE,
@@ -583,7 +583,18 @@ await describe('OCPP16ServiceUtils — pure functions', async () => {
 
       const result = OCPP16ServiceUtils.buildStatusNotificationRequest(input)
 
-      assert.strictEqual(result.errorCode, ChargePointErrorCode.NO_ERROR)
+      assert.strictEqual(result.errorCode, ChargePointErrorCode.CONNECTOR_LOCK_FAILURE)
+    })
+
+    await it('should pass through undefined errorCode when not set in payload', () => {
+      const input = {
+        connectorId: 1,
+        status: OCPP16ChargePointStatus.Available,
+      } as unknown as OCPP16StatusNotificationRequest
+
+      const result = OCPP16ServiceUtils.buildStatusNotificationRequest(input)
+
+      assert.strictEqual(result.errorCode, undefined)
     })
   })
 
index 31743b34ef9204e4e5501569123fc1b693375699..d8c67310b7c8a505fce7adaaf3e232647933a65f 100644 (file)
@@ -1,5 +1,13 @@
 import { Command, Option } from 'commander'
-import { buildAuthorizePayload, OCPPVersion, ProcedureName, type RequestPayload } from 'ui-common'
+import {
+  buildAuthorizePayload,
+  buildStatusNotificationPayload,
+  type ChargePointStatus,
+  type OCPP16ChargePointErrorCode,
+  OCPPVersion,
+  ProcedureName,
+  type RequestPayload,
+} from 'ui-common'
 
 import {
   handleActionErrors,
@@ -180,29 +188,20 @@ export const createOcppCommands = (program: Command): Command => {
               program,
               hashIds
             )
-            switch (ocppVersion) {
-              case OCPPVersion.VERSION_16:
-                if (options.errorCode == null) {
-                  throw new Error('--error-code is required for OCPP 1.6 stations')
-                }
-                payload = {
-                  connectorId: options.connectorId,
-                  errorCode: options.errorCode,
-                  status: options.status,
-                  ...buildHashIdsPayload(resolvedHashIds),
-                }
-                break
-              case OCPPVersion.VERSION_20:
-              case OCPPVersion.VERSION_201:
-                payload = {
-                  connectorId: options.connectorId,
-                  connectorStatus: options.status,
-                  ...(options.evseId != null && { evseId: options.evseId }),
-                  ...buildHashIdsPayload(resolvedHashIds),
+            if (ocppVersion === OCPPVersion.VERSION_16 && options.errorCode == null) {
+              throw new Error('--error-code is required for OCPP 1.6 stations')
+            }
+            payload = {
+              ...buildStatusNotificationPayload(
+                options.connectorId,
+                options.status as ChargePointStatus,
+                ocppVersion,
+                {
+                  errorCode: options.errorCode as OCPP16ChargePointErrorCode | undefined,
+                  evseId: options.evseId,
                 }
-                break
-              default:
-                throw new Error(UNSUPPORTED_OCPP_VERSION_ERROR)
+              ),
+              ...buildHashIdsPayload(resolvedHashIds),
             }
             await runAction(program, ProcedureName.STATUS_NOTIFICATION, payload, undefined, config)
           } else {
index 9508eadd084ebcbd4af884ee943ecd0db161ae78..c983179400b584581608d7e6f8355dd15eb916a5 100644 (file)
@@ -1,7 +1,11 @@
 import chalk from 'chalk'
 import assert from 'node:assert'
 import { describe, it } from 'node:test'
-import { OCPP16AvailabilityType, OCPP16ChargePointStatus } from 'ui-common'
+import {
+  OCPP16AvailabilityType,
+  OCPP16ChargePointStatus,
+  OCPP20ConnectorStatusEnumType,
+} from 'ui-common'
 
 import {
   formatConnectors,
@@ -116,7 +120,7 @@ await describe('format helpers', async () => {
                   connectorId: 2,
                   connectorStatus: {
                     availability: OCPP16AvailabilityType.OPERATIVE,
-                    status: OCPP16ChargePointStatus.OCCUPIED,
+                    status: OCPP20ConnectorStatusEnumType.OCCUPIED,
                   },
                 },
               ],
index f149f1e6730f1feee7a1cf77ab8d5f5f3939ef10..94d0746bf52014e1593bba52e74f6342e0d36a6a 100644 (file)
@@ -23,12 +23,30 @@ export enum OCPP16AvailabilityType {
   OPERATIVE = 'Operative',
 }
 
+export enum OCPP16ChargePointErrorCode {
+  CONNECTOR_LOCK_FAILURE = 'ConnectorLockFailure',
+  EV_COMMUNICATION_ERROR = 'EVCommunicationError',
+  GROUND_FAILURE = 'GroundFailure',
+  HIGH_TEMPERATURE = 'HighTemperature',
+  INTERNAL_ERROR = 'InternalError',
+  LOCAL_LIST_CONFLICT = 'LocalListConflict',
+  NO_ERROR = 'NoError',
+  OTHER_ERROR = 'OtherError',
+  OVER_CURRENT_FAILURE = 'OverCurrentFailure',
+  OVER_VOLTAGE = 'OverVoltage',
+  POWER_METER_FAILURE = 'PowerMeterFailure',
+  POWER_SWITCH_FAILURE = 'PowerSwitchFailure',
+  READER_FAILURE = 'ReaderFailure',
+  RESET_FAILURE = 'ResetFailure',
+  UNDER_VOLTAGE = 'UnderVoltage',
+  WEAK_SIGNAL = 'WeakSignal',
+}
+
 export enum OCPP16ChargePointStatus {
   AVAILABLE = 'Available',
   CHARGING = 'Charging',
   FAULTED = 'Faulted',
   FINISHING = 'Finishing',
-  OCCUPIED = 'Occupied',
   PREPARING = 'Preparing',
   RESERVED = 'Reserved',
   SUSPENDED_EV = 'SuspendedEV',
@@ -87,6 +105,14 @@ export enum OCPP16RequestCommand {
   STOP_TRANSACTION = 'StopTransaction',
 }
 
+export enum OCPP20ConnectorStatusEnumType {
+  AVAILABLE = 'Available',
+  FAULTED = 'Faulted',
+  OCCUPIED = 'Occupied',
+  RESERVED = 'Reserved',
+  UNAVAILABLE = 'Unavailable',
+}
+
 export enum OCPP20IdTokenEnumType {
   CENTRAL = 'Central',
   EMAID = 'eMAID',
@@ -172,7 +198,7 @@ export type AvailabilityType = OCPP16AvailabilityType
 
 export type BootNotificationResponse = OCPP16BootNotificationResponse
 
-export type ChargePointStatus = OCPP16ChargePointStatus
+export type ChargePointStatus = OCPP16ChargePointStatus | OCPP20ConnectorStatusEnumType
 
 export interface ChargingStationData extends JsonObject {
   automaticTransactionGenerator?: ATGConfiguration
@@ -285,6 +311,7 @@ export interface ConnectorStatus extends JsonObject {
   availability: AvailabilityType
   bootStatus?: ChargePointStatus
   energyActiveImportRegisterValue?: number // In Wh
+  errorCode?: OCPP16ChargePointErrorCode
   idTagAuthorized?: boolean
   idTagLocalAuthorized?: boolean
   localAuthorizeIdTag?: string
index c1c7b74af0421a2f3c9c1cb0f988b8cb8dce1ec1..1753bcadba0319f05e65bf0631f93a76ceecd86f 100644 (file)
@@ -1,4 +1,6 @@
 import {
+  type ChargePointStatus,
+  type OCPP16ChargePointErrorCode,
   OCPP20IdTokenEnumType,
   type OCPP20IdTokenType,
   OCPP20TransactionEventEnumType,
@@ -75,6 +77,38 @@ export function buildStartTransactionPayload (
   }
 }
 
+/**
+ * Builds a StatusNotification request payload adapted to the station's OCPP version.
+ * @param connectorId - Connector identifier
+ * @param status - Connector status value (OCPP 1.6 or 2.0.x)
+ * @param ocppVersion - Target OCPP version
+ * @param options - Optional fields
+ * @param options.errorCode - OCPP 1.6 error code (ignored for 2.0.x)
+ * @param options.evseId - EVSE identifier (relevant for both versions, auto-resolved by backend if omitted)
+ * @returns StatusNotification request payload
+ */
+export function buildStatusNotificationPayload (
+  connectorId: number,
+  status: ChargePointStatus,
+  ocppVersion: OCPPVersion | undefined,
+  options?: { errorCode?: OCPP16ChargePointErrorCode; evseId?: number }
+): RequestPayload {
+  if (isOCPP20x(ocppVersion)) {
+    return {
+      connectorId,
+      connectorStatus: status,
+      ...(options?.evseId != null && { evseId: options.evseId }),
+    }
+  }
+  assertOCPP16OrUndefined(ocppVersion)
+  return {
+    connectorId,
+    status,
+    ...(options?.errorCode != null && { errorCode: options.errorCode }),
+    ...(options?.evseId != null && { evseId: options.evseId }),
+  }
+}
+
 /**
  * Builds a StopTransaction/TransactionEvent payload adapted to the station's OCPP version.
  * @param transactionId - Transaction identifier (integer for 1.6, string for 2.0.x)
index 40b03214c390c47e2a60359639c694799e7442d5..cca323776af3983a9e7421b18c0a9f535c640fd8 100644 (file)
@@ -3,10 +3,13 @@ import type { OCPPVersion } from 'ui-common'
 import {
   buildAuthorizePayload,
   buildStartTransactionPayload,
+  buildStatusNotificationPayload,
   buildStopTransactionPayload,
+  type ChargePointStatus,
   type ChargingStationOptions,
   createBrowserWsAdapter,
   isOCPP20x,
+  type OCPP16ChargePointErrorCode,
   ProcedureName,
   type RequestPayload,
   type ResponsePayload,
@@ -135,6 +138,23 @@ export class UIClient {
     })
   }
 
+  public async setConnectorStatus (
+    hashId: string,
+    connectorId: number,
+    status: ChargePointStatus,
+    evseId?: number,
+    ocppVersion?: OCPPVersion,
+    errorCode?: OCPP16ChargePointErrorCode
+  ): Promise<ResponsePayload> {
+    return this.sendRequest(ProcedureName.STATUS_NOTIFICATION, {
+      hashIds: [hashId],
+      ...buildStatusNotificationPayload(connectorId, status, ocppVersion, {
+        errorCode,
+        evseId,
+      }),
+    })
+  }
+
   public async setSupervisionUrl (
     hashId: string,
     supervisionUrl: string,
index 64570b93464608669bbe57460ea8ae34481a0344..4fdd3de54b419ce8c8cd4bfdd4e26b7a159d2d43 100644 (file)
@@ -1,9 +1,10 @@
 /**
  * @file useConnectorActions.ts
- * @description Headless composable for connector-level actions (stop transaction, lock/unlock, ATG toggle).
+ * @description Headless composable for connector-level actions (stop transaction, lock/unlock, ATG toggle, set connector status).
  */
 import type { OCPPVersion } from 'ui-common'
 
+import { type ChargePointStatus, type OCPP16ChargePointErrorCode } from 'ui-common'
 import { computed, type MaybeRefOrGetter, readonly, toValue } from 'vue'
 import { useToast } from 'vue-toast-notification'
 
@@ -12,8 +13,9 @@ import { useAsyncAction } from '@/shared/composables/useAsyncAction.js'
 
 interface ConnectorActionsDeps {
   connectorId: MaybeRefOrGetter<number>
+  evseId?: MaybeRefOrGetter<number | undefined>
   hashId: MaybeRefOrGetter<string>
-  onRefresh?: () => void
+  ocppVersion?: MaybeRefOrGetter<OCPPVersion | undefined>
 }
 
 /**
@@ -23,7 +25,12 @@ interface ConnectorActionsDeps {
  */
 export function useConnectorActions (deps: ConnectorActionsDeps): {
   lockConnector: () => void
-  pending: Readonly<{ atg: boolean; lock: boolean; stopTx: boolean }>
+  pending: Readonly<{ atg: boolean; lock: boolean; setStatus: boolean; stopTx: boolean }>
+  setConnectorStatus: (
+    status: ChargePointStatus,
+    onSuccess?: () => void,
+    errorCode?: OCPP16ChargePointErrorCode
+  ) => void
   startATG: () => void
   stopATG: () => void
   stopTransaction: (
@@ -34,13 +41,17 @@ export function useConnectorActions (deps: ConnectorActionsDeps): {
 } {
   const $uiClient = useUIClient()
   const $toast = useToast()
-  const { pending, run } = useAsyncAction(
-    { atg: false, lock: false, stopTx: false },
-    deps.onRefresh
-  )
+  const { pending, run } = useAsyncAction({
+    atg: false,
+    lock: false,
+    setStatus: false,
+    stopTx: false,
+  })
 
   const hashId = computed(() => toValue(deps.hashId))
   const connectorId = computed(() => toValue(deps.connectorId))
+  const evseId = computed(() => toValue(deps.evseId))
+  const ocppVersion = computed(() => toValue(deps.ocppVersion))
 
   const stopTransaction = (
     transactionId: null | number | string | undefined,
@@ -94,9 +105,31 @@ export function useConnectorActions (deps: ConnectorActionsDeps): {
     })
   }
 
+  const setConnectorStatus = (
+    status: ChargePointStatus,
+    onSuccess?: () => void,
+    errorCode?: OCPP16ChargePointErrorCode
+  ): void => {
+    run('setStatus', {
+      action: () =>
+        $uiClient.setConnectorStatus(
+          hashId.value,
+          connectorId.value,
+          status,
+          evseId.value,
+          ocppVersion.value,
+          errorCode
+        ),
+      errorMsg: 'Error setting connector status',
+      onSuccess,
+      successMsg: 'Connector status updated',
+    })
+  }
+
   return {
     lockConnector,
     pending: readonly(pending),
+    setConnectorStatus,
     startATG,
     stopATG,
     stopTransaction,
index fe5df88aabc7c9ddc9ccd94100d2685359fa0a5c..ba75298cd6807c873f34b99faa5e367acc4936ab 100644 (file)
         :on="() => startAutomaticTransactionGenerator()"
         on-label="Start ATG"
       />
+      <select
+        v-model="selectedStatus"
+        class="connector-status-select"
+        :aria-label="`Set status for connector ${connectorId}`"
+        @change="applyConnectorStatus"
+      >
+        <option
+          v-for="s in statusOptions"
+          :key="s"
+          :value="s"
+        >
+          {{ s }}
+        </option>
+      </select>
+      <select
+        v-if="!isOCPP20x(ocppVersion)"
+        v-model="selectedErrorCode"
+        class="connector-status-select"
+        :aria-label="`Set error code for connector ${connectorId}`"
+        @change="applyConnectorStatus"
+      >
+        <option
+          v-for="e in errorCodeOptions"
+          :key="e"
+          :value="e"
+        >
+          {{ e }}
+        </option>
+      </select>
     </td>
   </tr>
 </template>
 
 <script setup lang="ts">
-import type { ConnectorStatus, OCPPVersion, Status } from 'ui-common'
+import type { ChargePointStatus, ConnectorStatus, OCPPVersion, Status } from 'ui-common'
 
-import { computed } from 'vue'
+import {
+  isOCPP20x,
+  OCPP16ChargePointErrorCode,
+  OCPP16ChargePointStatus,
+  OCPP20ConnectorStatusEnumType,
+} from 'ui-common'
+import { computed, ref, watch } from 'vue'
 import { useRouter } from 'vue-router'
 
 import { EMPTY_VALUE_PLACEHOLDER, ROUTE_NAMES } from '@/core/index.js'
@@ -88,23 +123,54 @@ const props = defineProps<{
   ocppVersion?: OCPPVersion
 }>()
 
-const emit = defineEmits<{ 'need-refresh': [] }>()
+defineEmits<{ 'need-refresh': [] }>()
 
 const $router = useRouter()
 
 const {
   lockConnector,
+  setConnectorStatus,
   startATG: startAutomaticTransactionGenerator,
   stopATG: stopAutomaticTransactionGenerator,
   stopTransaction: doStopTransaction,
   unlockConnector,
 } = useConnectorActions({
   connectorId: computed(() => props.connectorId),
+  evseId: computed(() => props.evseId),
   hashId: computed(() => props.hashId),
-  onRefresh: () => emit('need-refresh'),
+  ocppVersion: computed(() => props.ocppVersion),
 })
 
+const statusOptions = computed(() =>
+  isOCPP20x(props.ocppVersion)
+    ? Object.values(OCPP20ConnectorStatusEnumType)
+    : Object.values(OCPP16ChargePointStatus)
+)
+const errorCodeOptions = Object.values(OCPP16ChargePointErrorCode)
+const selectedStatus = ref<ChargePointStatus>(
+  isOCPP20x(props.ocppVersion)
+    ? ((props.connector.status as OCPP20ConnectorStatusEnumType | undefined) ??
+        OCPP20ConnectorStatusEnumType.AVAILABLE)
+    : ((props.connector.status as OCPP16ChargePointStatus | undefined) ??
+        OCPP16ChargePointStatus.AVAILABLE)
+)
+const selectedErrorCode = ref<OCPP16ChargePointErrorCode>(OCPP16ChargePointErrorCode.NO_ERROR)
+
+watch(
+  () => props.connector.status,
+  newStatus => {
+    if (newStatus != null) {
+      selectedStatus.value = newStatus
+    }
+  }
+)
+
 const stopTransaction = (): void => {
   doStopTransaction(props.connector.transactionId, props.ocppVersion)
 }
+
+const applyConnectorStatus = (): void => {
+  const errorCode = isOCPP20x(props.ocppVersion) ? undefined : selectedErrorCode.value
+  setConnectorStatus(selectedStatus.value, undefined, errorCode)
+}
 </script>
index f24fd1bcf771248333545168e705e28e42fc9ee2..8c744b67b32bc17d8e90a4f6eef306e2704e82b2 100644 (file)
@@ -5,6 +5,17 @@
       { 'modern-connector--active': connector.transactionStarted === true },
     ]"
   >
+    <SetConnectorStatusDialog
+      v-if="showSetConnectorStatus"
+      :charging-station-id="chargingStationId"
+      :connector-id="connectorId"
+      :current-error-code="connector.errorCode"
+      :current-status="connector.status"
+      :evse-id="evseId"
+      :hash-id="hashId"
+      :ocpp-version="ocppVersion"
+      @close="showSetConnectorStatus = false"
+    />
     <div class="modern-connector__gutter">
       <span class="modern-connector__id">
         {{ identifier }}
     </div>
     <div class="modern-connector__content">
       <div class="modern-connector__meta">
-        <StatePill :variant="statusVariant">
+        <button
+          type="button"
+          :class="['modern-pill', 'modern-pill--editable', `modern-pill--${statusVariant}`]"
+          :title="statusTooltip"
+          :aria-label="`Connector status: ${connector.status ?? 'unknown'}. Click to change.`"
+          aria-haspopup="dialog"
+          @click="openSetConnectorStatus"
+        >
           {{ connector.status ?? 'unknown' }}
-        </StatePill>
+          <svg
+            viewBox="0 0 28 28"
+            aria-hidden="true"
+            fill="none"
+            stroke="currentColor"
+            stroke-width="3"
+            stroke-linecap="round"
+            stroke-linejoin="round"
+          >
+            <path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
+          </svg>
+        </button>
         <StatePill
           v-if="connector.locked === true"
           variant="warn"
 </template>
 
 <script setup lang="ts">
-import type { ConnectorStatus, OCPPVersion, Status } from 'ui-common'
-
-import { computed } from 'vue'
+import {
+  type ConnectorStatus,
+  OCPP16ChargePointErrorCode,
+  type OCPPVersion,
+  type Status,
+} from 'ui-common'
+import { computed, ref } from 'vue'
 
 import { WH_PER_KWH } from '@/core/index.js'
 import { useConnectorActions } from '@/shared/composables/useConnectorActions.js'
 import { getConnectorStatusVariant } from '@/shared/utils/stationStatus.js'
 
 import ActionButton from './ActionButton.vue'
+import SetConnectorStatusDialog from './dialogs/SetConnectorStatusDialog.vue'
 import StatePill from './StatePill.vue'
 
 const props = defineProps<{
@@ -203,8 +237,19 @@ const identifier = computed(() =>
   props.evseId != null ? `${props.evseId}/${props.connectorId}` : String(props.connectorId)
 )
 
+const showSetConnectorStatus = ref(false)
+
 const statusVariant = computed(() => getConnectorStatusVariant(props.connector.status))
 
+const statusTooltip = computed(() => {
+  const status = props.connector.status ?? 'unknown'
+  const errorCode = props.connector.errorCode
+  if (errorCode != null && errorCode !== OCPP16ChargePointErrorCode.NO_ERROR) {
+    return `${status} (${errorCode})`
+  }
+  return status
+})
+
 // Effectively locked when explicitly locked OR transaction active (physical lock engages).
 const effectiveLocked = computed(
   () => props.connector.locked === true || props.connector.transactionStarted === true
@@ -242,6 +287,10 @@ const stopTransaction = (): void => {
   doStopTransaction(props.connector.transactionId, props.ocppVersion)
 }
 
+const openSetConnectorStatus = (): void => {
+  showSetConnectorStatus.value = true
+}
+
 const openStartTransaction = (): void => {
   emit('open-start-tx', {
     chargingStationId: props.chargingStationId,
diff --git a/ui/web/src/skins/modern/components/dialogs/SetConnectorStatusDialog.vue b/ui/web/src/skins/modern/components/dialogs/SetConnectorStatusDialog.vue
new file mode 100644 (file)
index 0000000..d9beaa3
--- /dev/null
@@ -0,0 +1,143 @@
+<template>
+  <Modal
+    :title="`Set connector status — ${chargingStationId}`"
+    @close="close"
+  >
+    <form
+      class="modern-form"
+      @submit.prevent="submit"
+    >
+      <p class="modern-dialog__target-label">
+        {{ targetLabel }}
+      </p>
+      <div class="modern-form__row">
+        <label
+          class="modern-form__label"
+          for="modern-connector-status-select"
+        >Status</label>
+        <select
+          id="modern-connector-status-select"
+          v-model="selectedStatus"
+          class="modern-form__input"
+        >
+          <option
+            v-for="s in statusOptions"
+            :key="s"
+            :value="s"
+          >
+            {{ s }}
+          </option>
+        </select>
+        <span class="modern-form__hint"> The OCPP status to simulate on this connector. </span>
+      </div>
+      <div
+        v-if="!isOCPP20x(ocppVersion)"
+        class="modern-form__row"
+      >
+        <label
+          class="modern-form__label"
+          for="modern-connector-error-code-select"
+        >Error Code</label>
+        <select
+          id="modern-connector-error-code-select"
+          v-model="selectedErrorCode"
+          class="modern-form__input"
+        >
+          <option
+            v-for="e in errorCodeOptions"
+            :key="e"
+            :value="e"
+          >
+            {{ e }}
+          </option>
+        </select>
+        <span class="modern-form__hint"> OCPP 1.6 error code to simulate (NoError = normal). </span>
+      </div>
+    </form>
+    <template #footer>
+      <ActionButton
+        variant="ghost"
+        @click="close"
+      >
+        Cancel
+      </ActionButton>
+      <ActionButton
+        variant="primary"
+        :pending="pendingState.setStatus"
+        @click="submit"
+      >
+        Set Status
+      </ActionButton>
+    </template>
+  </Modal>
+</template>
+
+<script setup lang="ts">
+import type { ChargePointStatus, OCPPVersion } from 'ui-common'
+
+import {
+  isOCPP20x,
+  OCPP16ChargePointErrorCode,
+  OCPP16ChargePointStatus,
+  OCPP20ConnectorStatusEnumType,
+} from 'ui-common'
+import { computed, ref } from 'vue'
+
+import { useConnectorActions } from '@/shared/composables/useConnectorActions.js'
+
+import ActionButton from '../ActionButton.vue'
+import Modal from '../ModernModal.vue'
+
+const props = defineProps<{
+  chargingStationId: string
+  connectorId: number
+  currentErrorCode?: OCPP16ChargePointErrorCode
+  currentStatus?: ChargePointStatus
+  evseId?: number
+  hashId: string
+  ocppVersion?: OCPPVersion
+}>()
+
+const emit = defineEmits<{ close: [] }>()
+
+const statusOptions = computed(() =>
+  isOCPP20x(props.ocppVersion)
+    ? Object.values(OCPP20ConnectorStatusEnumType)
+    : Object.values(OCPP16ChargePointStatus)
+)
+
+const errorCodeOptions = Object.values(OCPP16ChargePointErrorCode)
+
+const defaultStatus = isOCPP20x(props.ocppVersion)
+  ? OCPP20ConnectorStatusEnumType.AVAILABLE
+  : OCPP16ChargePointStatus.AVAILABLE
+
+const selectedStatus = ref<ChargePointStatus>(props.currentStatus ?? defaultStatus)
+
+const selectedErrorCode = ref<OCPP16ChargePointErrorCode>(
+  props.currentErrorCode ?? OCPP16ChargePointErrorCode.NO_ERROR
+)
+
+const { pending: pendingState, setConnectorStatus } = useConnectorActions({
+  connectorId: computed(() => props.connectorId),
+  evseId: computed(() => props.evseId),
+  hashId: computed(() => props.hashId),
+  ocppVersion: computed(() => props.ocppVersion),
+})
+
+const targetLabel = computed(() =>
+  props.evseId != null
+    ? `EVSE ${String(props.evseId)} / Connector ${String(props.connectorId)}`
+    : `Connector ${String(props.connectorId)}`
+)
+
+const close = (): void => {
+  emit('close')
+}
+
+const submit = (): void => {
+  if (pendingState.setStatus) return
+  const errorCode = isOCPP20x(props.ocppVersion) ? undefined : selectedErrorCode.value
+  setConnectorStatus(selectedStatus.value, close, errorCode)
+}
+</script>
index 0e548d7eb24d6811eb569eeae2bcf09633ff1e31..923627cf544debce23b0ea53fdbb75d1cb9a9ebb 100644 (file)
@@ -873,6 +873,32 @@ html[data-skin='modern'] #app {
   border-color: color-mix(in srgb, var(--color-state-err) 35%, transparent);
 }
 
+/* `color: inherit` overrides the UA <button> colour so the variant tint applies. */
+.modern-pill--editable {
+  color: inherit;
+  cursor: pointer;
+  gap: 6px;
+  transition: border-color var(--skin-transition);
+}
+
+.modern-pill--editable:hover {
+  border-color: currentColor;
+}
+
+.modern-pill--editable:focus-visible {
+  outline: 2px solid var(--color-primary);
+  outline-offset: 1px;
+}
+
+.modern-pill--editable > svg {
+  width: 1.6cap;
+  height: 1.6cap;
+  flex-shrink: 0;
+  /* Negative block margins shrink the icon's outer box back to the text's
+   * line-height so the larger icon overflows visually without expanding the pill. */
+  margin-block: calc((1em - 1.6cap) / 2);
+}
+
 /* ── Buttons — flat Material-style ─────────────────────────────────── */
 .modern-btn {
   display: inline-flex;
index e763c8f85a2e550ce45c44fe245274c346429973..f51e23b88424649919d89bb5dcf624e8a5483079 100644 (file)
@@ -21,6 +21,7 @@ export interface MockUIClient {
   openConnection: ReturnType<typeof vi.fn>
   registerWSEventListener: ReturnType<typeof vi.fn>
   setConfiguration: ReturnType<typeof vi.fn>
+  setConnectorStatus: ReturnType<typeof vi.fn>
   setSupervisionUrl: ReturnType<typeof vi.fn>
   simulatorState: ReturnType<typeof vi.fn>
   startAutomaticTransactionGenerator: ReturnType<typeof vi.fn>
@@ -150,6 +151,7 @@ export function createMockUIClient (): MockUIClient {
     openConnection: vi.fn().mockResolvedValue(successResponse),
     registerWSEventListener: vi.fn(),
     setConfiguration: vi.fn(),
+    setConnectorStatus: vi.fn().mockResolvedValue(successResponse),
     setSupervisionUrl: vi.fn().mockResolvedValue(successResponse),
     simulatorState: vi
       .fn()
index b90b075ecae5e7005cfbc602f49d1d943cc7386f..56ddeaf6fc5447bb83bcf52bc2c4a4fc38715b3d 100644 (file)
@@ -4,7 +4,7 @@
  */
 import type { ChargingStationData } from 'ui-common'
 
-import { OCPP16ChargePointStatus } from 'ui-common'
+import { OCPP16ChargePointStatus, OCPP20ConnectorStatusEnumType } from 'ui-common'
 import { describe, expect, it } from 'vitest'
 
 import {
@@ -27,7 +27,7 @@ describe('stationStatus', () => {
     })
 
     it('should return warn for Occupied', () => {
-      const result = getConnectorStatusVariant(OCPP16ChargePointStatus.OCCUPIED)
+      const result = getConnectorStatusVariant(OCPP20ConnectorStatusEnumType.OCCUPIED)
       expect(result).toBe('warn')
     })
 
index a28a6211d81cdb66df3b38f69d05e9a5a50fb2e9..85114d2ab288a16dbce68bf60810c1949b03c00e 100644 (file)
@@ -1,10 +1,10 @@
 /**
  * @file Tests for useConnectorActions composable
  * @description Verifies connector-level actions (stop transaction, lock/unlock, ATG start/stop),
- *   pending state guards, toast notifications, and onRefresh callback invocation.
+ *   pending state guards, toast notifications, and action dispatching.
  */
 import { flushPromises } from '@vue/test-utils'
-import { OCPPVersion } from 'ui-common'
+import { OCPP16ChargePointStatus, OCPP20ConnectorStatusEnumType, OCPPVersion } from 'ui-common'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 
 import { toastMock } from '../../../setup.js'
@@ -269,35 +269,92 @@ describe('useConnectorActions', () => {
     })
   })
 
-  describe('onRefresh', () => {
-    it('should call onRefresh callback after successful action', async () => {
-      const onRefresh = vi.fn()
-      const [{ lockConnector }] = withSetup(() =>
-        useConnectorActions({ connectorId, hashId, onRefresh })
+  describe('setConnectorStatus', () => {
+    it('should call uiClient.setConnectorStatus with hashId, connectorId, and status', async () => {
+      const [{ setConnectorStatus }] = withSetup(() => useConnectorActions({ connectorId, hashId }))
+      setConnectorStatus(OCPP16ChargePointStatus.FAULTED)
+      await flushPromises()
+      expect(mockClient.setConnectorStatus).toHaveBeenCalledWith(
+        hashId,
+        connectorId,
+        OCPP16ChargePointStatus.FAULTED,
+        undefined,
+        undefined,
+        undefined
       )
-      lockConnector()
+    })
+
+    it('should pass evseId when provided', async () => {
+      const evseId = 2
+      const [{ setConnectorStatus }] = withSetup(() =>
+        useConnectorActions({ connectorId, evseId, hashId })
+      )
+      setConnectorStatus(OCPP16ChargePointStatus.AVAILABLE)
       await flushPromises()
-      expect(onRefresh).toHaveBeenCalledOnce()
+      expect(mockClient.setConnectorStatus).toHaveBeenCalledWith(
+        hashId,
+        connectorId,
+        OCPP16ChargePointStatus.AVAILABLE,
+        evseId,
+        undefined,
+        undefined
+      )
     })
 
-    it('should not call onRefresh on failure', async () => {
-      const onRefresh = vi.fn()
-      mockClient.lockConnector.mockRejectedValueOnce(new Error('fail'))
-      const [{ lockConnector }] = withSetup(() =>
-        useConnectorActions({ connectorId, hashId, onRefresh })
+    it('should show success toast on successful status update', async () => {
+      const [{ setConnectorStatus }] = withSetup(() => useConnectorActions({ connectorId, hashId }))
+      setConnectorStatus(OCPP16ChargePointStatus.UNAVAILABLE)
+      await flushPromises()
+      expect(toastMock.success).toHaveBeenCalledWith('Connector status updated')
+    })
+
+    it('should show error toast on failure', async () => {
+      mockClient.setConnectorStatus.mockRejectedValueOnce(new Error('fail'))
+      const [{ setConnectorStatus }] = withSetup(() => useConnectorActions({ connectorId, hashId }))
+      setConnectorStatus(OCPP16ChargePointStatus.FAULTED)
+      await flushPromises()
+      expect(toastMock.error).toHaveBeenCalledWith('Error setting connector status')
+    })
+
+    it('should set pending.setStatus while action is in progress', async () => {
+      let resolveAction!: (value: unknown) => void
+      mockClient.setConnectorStatus.mockReturnValueOnce(
+        new Promise(resolve => {
+          resolveAction = resolve
+        })
       )
-      lockConnector()
+      const [{ pending, setConnectorStatus }] = withSetup(() =>
+        useConnectorActions({ connectorId, hashId })
+      )
+      setConnectorStatus(OCPP16ChargePointStatus.FAULTED)
+      expect(pending.setStatus).toBe(true)
+      resolveAction({ status: 'success' })
       await flushPromises()
-      expect(onRefresh).not.toHaveBeenCalled()
+      expect(pending.setStatus).toBe(false)
     })
-  })
 
-  describe('pending state initialization', () => {
-    it('should initialize all pending keys to false', () => {
-      const [{ pending }] = withSetup(() => useConnectorActions({ connectorId, hashId }))
-      expect(pending.atg).toBe(false)
-      expect(pending.lock).toBe(false)
-      expect(pending.stopTx).toBe(false)
+    it('should pass ocppVersion when provided', async () => {
+      const [{ setConnectorStatus }] = withSetup(() =>
+        useConnectorActions({ connectorId, hashId, ocppVersion: OCPPVersion.VERSION_201 })
+      )
+      setConnectorStatus(OCPP20ConnectorStatusEnumType.AVAILABLE)
+      await flushPromises()
+      expect(mockClient.setConnectorStatus).toHaveBeenCalledWith(
+        hashId,
+        connectorId,
+        OCPP20ConnectorStatusEnumType.AVAILABLE,
+        undefined,
+        OCPPVersion.VERSION_201,
+        undefined
+      )
+    })
+
+    it('should invoke onSuccess callback after successful status update', async () => {
+      const onSuccess = vi.fn()
+      const [{ setConnectorStatus }] = withSetup(() => useConnectorActions({ connectorId, hashId }))
+      setConnectorStatus(OCPP16ChargePointStatus.AVAILABLE, onSuccess)
+      await flushPromises()
+      expect(onSuccess).toHaveBeenCalledOnce()
     })
   })
 
index 0596e3c3b567eaa52cc0164679b33b57f71b1093..0e79513b551efcedd5ff56104c822659761ed9e5 100644 (file)
@@ -3,7 +3,13 @@
  * @description Unit tests for classic skin CSConnector component — connector row rendering and actions.
  */
 import { flushPromises, mount } from '@vue/test-utils'
-import { type ConnectorStatus, OCPP16ChargePointStatus, OCPPVersion } from 'ui-common'
+import {
+  type ConnectorStatus,
+  OCPP16ChargePointErrorCode,
+  OCPP16ChargePointStatus,
+  OCPP20ConnectorStatusEnumType,
+  OCPPVersion,
+} from 'ui-common'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 
 import { uiClientKey } from '@/core/index.js'
@@ -173,7 +179,6 @@ describe('CSConnector', () => {
       lockProps.on?.()
       await flushPromises()
       expect(mockClient.lockConnector).toHaveBeenCalled()
-      expect(wrapper.emitted('need-refresh')).toHaveLength(1)
     })
 
     it('should call unlockConnector', async () => {
@@ -254,6 +259,51 @@ describe('CSConnector', () => {
       await flushPromises()
       expect(toastMock.error).toHaveBeenCalled()
     })
+
+    it('should render OCPP 1.6 status options by default', () => {
+      const wrapper = mountConnector()
+      const selects = wrapper.findAll('select.connector-status-select')
+      const statusSelect = selects[0]
+      const options = statusSelect.findAll('option')
+      expect(options.length).toBe(Object.values(OCPP16ChargePointStatus).length)
+    })
+
+    it('should render OCPP 2.0.x status options for OCPP 2.0.1 station', () => {
+      const wrapper = mountConnector({ ocppVersion: OCPPVersion.VERSION_201 })
+      const selects = wrapper.findAll('select.connector-status-select')
+      const statusSelect = selects[0]
+      const options = statusSelect.findAll('option')
+      expect(options.length).toBe(Object.values(OCPP20ConnectorStatusEnumType).length)
+    })
+
+    it('should hide error code select for OCPP 2.0.x', () => {
+      const wrapper = mountConnector({ ocppVersion: OCPPVersion.VERSION_201 })
+      const selects = wrapper.findAll('select.connector-status-select')
+      expect(selects.length).toBe(1)
+    })
+
+    it('should show error code select for OCPP 1.6', () => {
+      const wrapper = mountConnector({ ocppVersion: OCPPVersion.VERSION_16 })
+      const selects = wrapper.findAll('select.connector-status-select')
+      expect(selects.length).toBe(2)
+      const errorOptions = selects[1].findAll('option')
+      expect(errorOptions.length).toBe(Object.values(OCPP16ChargePointErrorCode).length)
+    })
+
+    it('should call setConnectorStatus on status change', async () => {
+      const wrapper = mountConnector()
+      const selects = wrapper.findAll('select.connector-status-select')
+      await selects[0].setValue(OCPP16ChargePointStatus.FAULTED)
+      await flushPromises()
+      expect(mockClient.setConnectorStatus).toHaveBeenCalledWith(
+        TEST_HASH_ID,
+        1,
+        OCPP16ChargePointStatus.FAULTED,
+        undefined,
+        OCPPVersion.VERSION_16,
+        OCPP16ChargePointErrorCode.NO_ERROR
+      )
+    })
   })
 
   describe('start transaction navigation', () => {
index 0dedc111d9ecb6aae90f793bb695172cef067f60..d8bdc976e3401e77b26c6bcfd839e75ed7fc5869 100644 (file)
@@ -6,6 +6,7 @@ import { flushPromises, mount } from '@vue/test-utils'
 import {
   OCPP16AvailabilityType,
   OCPP16ChargePointStatus,
+  OCPP20ConnectorStatusEnumType,
   OCPPVersion,
   type Status,
 } from 'ui-common'
@@ -82,7 +83,7 @@ describe('ConnectorRow', () => {
     it.each<[string, string]>([
       [OCPP16ChargePointStatus.AVAILABLE, 'modern-pill--ok'],
       [OCPP16ChargePointStatus.CHARGING, 'modern-pill--warn'],
-      [OCPP16ChargePointStatus.OCCUPIED, 'modern-pill--warn'],
+      [OCPP20ConnectorStatusEnumType.OCCUPIED, 'modern-pill--warn'],
       [OCPP16ChargePointStatus.PREPARING, 'modern-pill--warn'],
       [OCPP16ChargePointStatus.FAULTED, 'modern-pill--err'],
       [OCPP16ChargePointStatus.UNAVAILABLE, 'modern-pill--err'],
index 5dfdb3a265b20a5e8e32ac48823b6f18eab9911f..1eefad8418d390993b150a780a298b502189f39c 100644 (file)
@@ -4,7 +4,14 @@
  *   Modal is mocked to skip the Teleport so wrapper.find() reaches dialog inputs.
  */
 import { flushPromises, mount } from '@vue/test-utils'
-import { OCPPVersion, ResponseStatus, ServerFailureError } from 'ui-common'
+import {
+  OCPP16ChargePointErrorCode,
+  OCPP16ChargePointStatus,
+  OCPP20ConnectorStatusEnumType,
+  OCPPVersion,
+  ResponseStatus,
+  ServerFailureError,
+} from 'ui-common'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { defineComponent, ref } from 'vue'
 
@@ -26,6 +33,7 @@ vi.mock('@/skins/modern/components/ModernModal.vue', () => ({
 
 import AddStationsDialog from '@/skins/modern/components/dialogs/AddStationsDialog.vue'
 import AuthorizeDialog from '@/skins/modern/components/dialogs/AuthorizeDialog.vue'
+import SetConnectorStatusDialog from '@/skins/modern/components/dialogs/SetConnectorStatusDialog.vue'
 import SetSupervisionUrlDialog from '@/skins/modern/components/dialogs/SetSupervisionUrlDialog.vue'
 import StartTransactionDialog from '@/skins/modern/components/dialogs/StartTransactionDialog.vue'
 
@@ -440,4 +448,94 @@ describe('Dialogs', () => {
       expect(wrapper.emitted('close')).toHaveLength(1)
     })
   })
+
+  describe('SetConnectorStatusDialog', () => {
+    const hashId = 'station-hash'
+    const connectorId = 1
+    const chargingStationId = 'CS001'
+
+    /**
+     * Mounts SetConnectorStatusDialog with optional prop overrides.
+     * @param props - Optional props to override defaults
+     * @param props.chargingStationId - Charging station display ID
+     * @param props.connectorId - Connector identifier
+     * @param props.evseId - EVSE identifier
+     * @param props.hashId - Station hash identifier
+     * @param props.ocppVersion - OCPP protocol version
+     * @returns Mounted component wrapper
+     */
+    function mountDialog (
+      props: {
+        chargingStationId?: string
+        connectorId?: number
+        evseId?: number
+        hashId?: string
+        ocppVersion?: OCPPVersion
+      } = {}
+    ) {
+      return mount(SetConnectorStatusDialog, {
+        global: {
+          provide: {
+            [uiClientKey as symbol]: mockClient,
+          },
+        },
+        props: {
+          chargingStationId,
+          connectorId,
+          hashId,
+          ...props,
+        },
+      })
+    }
+
+    it('should render OCPP 1.6 status options by default', () => {
+      const wrapper = mountDialog()
+      const options = wrapper.findAll('#modern-connector-status-select option')
+      const values = options.map(o => (o.element as HTMLOptionElement).value)
+      expect(values).toContain(OCPP16ChargePointStatus.FAULTED)
+      expect(values).toContain(OCPP16ChargePointStatus.CHARGING)
+      // OCPP 1.6 has more statuses than 2.0.x
+      expect(values.length).toBe(Object.values(OCPP16ChargePointStatus).length)
+    })
+
+    it('should render OCPP 2.0.x status options for OCPP 2.0.1 station', () => {
+      const wrapper = mountDialog({ ocppVersion: OCPPVersion.VERSION_201 })
+      const options = wrapper.findAll('#modern-connector-status-select option')
+      const values = options.map(o => (o.element as HTMLOptionElement).value)
+      expect(values).toContain(OCPP20ConnectorStatusEnumType.AVAILABLE)
+      expect(values).toContain(OCPP20ConnectorStatusEnumType.FAULTED)
+      // OCPP 2.0.x has fewer statuses than 1.6
+      expect(values.length).toBe(Object.values(OCPP20ConnectorStatusEnumType).length)
+    })
+
+    it('should call setConnectorStatus and close on submit', async () => {
+      const wrapper = mountDialog()
+      await wrapper
+        .find('#modern-connector-status-select')
+        .setValue(OCPP16ChargePointStatus.FAULTED)
+      await wrapper.findAll('.stub-modal__foot button')[1].trigger('click')
+      await flushPromises()
+      expect(mockClient.setConnectorStatus).toHaveBeenCalledWith(
+        hashId,
+        connectorId,
+        OCPP16ChargePointStatus.FAULTED,
+        undefined,
+        undefined,
+        OCPP16ChargePointErrorCode.NO_ERROR
+      )
+      expect(wrapper.emitted('close')).toHaveLength(1)
+    })
+
+    it('should display EVSE label when evseId is provided', () => {
+      const wrapper = mountDialog({ evseId: 2 })
+      expect(wrapper.text()).toContain('EVSE 2')
+      expect(wrapper.text()).toContain(`Connector ${String(connectorId)}`)
+    })
+
+    it('should emit close when cancel is clicked', async () => {
+      const wrapper = mountDialog()
+      await wrapper.findAll('.stub-modal__foot button')[0].trigger('click')
+      expect(wrapper.emitted('close')).toHaveLength(1)
+    })
+  })
 })