ResponseStatus,
StandardParametersKey,
type StartTransactionResponse,
+ type StatusNotificationRequest,
type StopTransactionRequest,
type StopTransactionResponse,
} from '../../types/index.js'
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'
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) => {
)
}
+ 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> {
import { OCPPError } from '../../../exception/index.js'
import {
+ ChargePointErrorCode,
ErrorType,
type JsonObject,
type JsonType,
...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)) &&
} from '../../../charging-station/index.js'
import { BaseError } from '../../../exception/index.js'
import {
- ChargePointErrorCode,
type ConfigurationKey,
type GenericResponse,
type MeterValuesRequest,
): OCPP16StatusNotificationRequest {
return {
connectorId: commandParams.connectorId,
- errorCode: ChargePointErrorCode.NO_ERROR,
+ errorCode: commandParams.errorCode,
status: commandParams.status,
} satisfies OCPP16StatusNotificationRequest
}
type ConnectorStatus,
ConnectorStatusEnum,
ErrorType,
+ type OCPP16ChargePointErrorCode,
OCPPVersion,
RequestCommand,
type StatusNotificationRequest,
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
>(chargingStation, RequestCommand.STATUS_NOTIFICATION, commandParams)
}
connectorStatus.status = status
+ connectorStatus.errorCode = errorCode
chargingStation.emitChargingStationEvent(ChargingStationEvents.connectorStatusChanged, {
connectorId,
...connectorStatus,
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'
bootStatus?: ConnectorStatusEnum
chargingProfiles?: ChargingProfile[]
energyActiveImportRegisterValue?: number // In Wh
+ errorCode?: OCPP16ChargePointErrorCode
idTagAuthorized?: boolean
idTagLocalAuthorized?: boolean
localAuthorizeIdTag?: string
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,
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)
})
})
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,
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 {
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,
connectorId: 2,
connectorStatus: {
availability: OCPP16AvailabilityType.OPERATIVE,
- status: OCPP16ChargePointStatus.OCCUPIED,
+ status: OCPP20ConnectorStatusEnumType.OCCUPIED,
},
},
],
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',
STOP_TRANSACTION = 'StopTransaction',
}
+export enum OCPP20ConnectorStatusEnumType {
+ AVAILABLE = 'Available',
+ FAULTED = 'Faulted',
+ OCCUPIED = 'Occupied',
+ RESERVED = 'Reserved',
+ UNAVAILABLE = 'Unavailable',
+}
+
export enum OCPP20IdTokenEnumType {
CENTRAL = 'Central',
EMAID = 'eMAID',
export type BootNotificationResponse = OCPP16BootNotificationResponse
-export type ChargePointStatus = OCPP16ChargePointStatus
+export type ChargePointStatus = OCPP16ChargePointStatus | OCPP20ConnectorStatusEnumType
export interface ChargingStationData extends JsonObject {
automaticTransactionGenerator?: ATGConfiguration
availability: AvailabilityType
bootStatus?: ChargePointStatus
energyActiveImportRegisterValue?: number // In Wh
+ errorCode?: OCPP16ChargePointErrorCode
idTagAuthorized?: boolean
idTagLocalAuthorized?: boolean
localAuthorizeIdTag?: string
import {
+ type ChargePointStatus,
+ type OCPP16ChargePointErrorCode,
OCPP20IdTokenEnumType,
type OCPP20IdTokenType,
OCPP20TransactionEventEnumType,
}
}
+/**
+ * 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)
import {
buildAuthorizePayload,
buildStartTransactionPayload,
+ buildStatusNotificationPayload,
buildStopTransactionPayload,
+ type ChargePointStatus,
type ChargingStationOptions,
createBrowserWsAdapter,
isOCPP20x,
+ type OCPP16ChargePointErrorCode,
ProcedureName,
type RequestPayload,
type ResponsePayload,
})
}
+ 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,
/**
* @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'
interface ConnectorActionsDeps {
connectorId: MaybeRefOrGetter<number>
+ evseId?: MaybeRefOrGetter<number | undefined>
hashId: MaybeRefOrGetter<string>
- onRefresh?: () => void
+ ocppVersion?: MaybeRefOrGetter<OCPPVersion | undefined>
}
/**
*/
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: (
} {
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,
})
}
+ 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,
: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'
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>
{ '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<{
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
doStopTransaction(props.connector.transactionId, props.ocppVersion)
}
+const openSetConnectorStatus = (): void => {
+ showSetConnectorStatus.value = true
+}
+
const openStartTransaction = (): void => {
emit('open-start-tx', {
chargingStationId: props.chargingStationId,
--- /dev/null
+<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>
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;
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>
openConnection: vi.fn().mockResolvedValue(successResponse),
registerWSEventListener: vi.fn(),
setConfiguration: vi.fn(),
+ setConnectorStatus: vi.fn().mockResolvedValue(successResponse),
setSupervisionUrl: vi.fn().mockResolvedValue(successResponse),
simulatorState: vi
.fn()
*/
import type { ChargingStationData } from 'ui-common'
-import { OCPP16ChargePointStatus } from 'ui-common'
+import { OCPP16ChargePointStatus, OCPP20ConnectorStatusEnumType } from 'ui-common'
import { describe, expect, it } from 'vitest'
import {
})
it('should return warn for Occupied', () => {
- const result = getConnectorStatusVariant(OCPP16ChargePointStatus.OCCUPIED)
+ const result = getConnectorStatusVariant(OCPP20ConnectorStatusEnumType.OCCUPIED)
expect(result).toBe('warn')
})
/**
* @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'
})
})
- 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()
})
})
* @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'
lockProps.on?.()
await flushPromises()
expect(mockClient.lockConnector).toHaveBeenCalled()
- expect(wrapper.emitted('need-refresh')).toHaveLength(1)
})
it('should call unlockConnector', async () => {
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', () => {
import {
OCPP16AvailabilityType,
OCPP16ChargePointStatus,
+ OCPP20ConnectorStatusEnumType,
OCPPVersion,
type Status,
} from 'ui-common'
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'],
* 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'
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'
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)
+ })
+ })
})