* feat(ui-server): allow per-call override of station identity and CSMS credentials
Extend ChargingStationOptions with baseName, fixedName, nameSuffix,
supervisionUser and supervisionPassword so that callers of the
addChargingStations UI procedure can fully customise a station without
editing template files.
Identity derivation (chargingStationId, hashId) moves to the tail of
getStationInfo, after setChargingStationOptions runs, so the merged
stationInfo is the single source of truth for baseName/fixedName/nameSuffix.
getChargingStationId now accepts a narrow StationIdentity pick that both
ChargingStationTemplate and ChargingStationInfo satisfy; getHashId grows
an optional chargingStationIdOverride so the new identity flows through
without altering the hashed payload shape for existing callers.
Default behaviour is unchanged: hash ID regression test pins the
pre-existing hash, and omitted options leave stationInfo untouched.
* docs(ui-server): sync ChargingStationOptions docs with source interface
Bring the README protocol reference, the MCP zod schema and the web UI
ChargingStationOptions type back in sync with src/types/ChargingStationWorker.ts.
Covers the newly added baseName, fixedName, nameSuffix, supervisionUser and
supervisionPassword fields, plus previously missing fields not yet surfaced
in the public docs.
* feat(ui-web): expose baseName, fixedName and CSMS credentials in add-station dialog
Surface the new addChargingStations options on the web UI form: a base
name text field, an "append counter to name" checkbox that controls
fixedName, and a supervision user/password pair alongside the existing
supervision URL. Empty fields are omitted from the payload so the
template defaults apply unchanged.
\ 1 Conflicts:
\ 1 ui/web/src/components/actions/AddChargingStations.vue
* feat(ui-server): allow setSupervisionUrl to update CSMS credentials
Extend the setSupervisionUrl UI procedure so callers can update the
supervision WebSocket's basic auth user and password on an already
running station, not only the URL. All three fields (url,
supervisionUser, supervisionPassword) become optional; the handler now
requires at least one to be set.
ChargingStation.setSupervisionUrl accepts the three optional arguments
and writes the non-URL fields onto stationInfo via saveStationInfo.
Changes take effect on the next WebSocket (re)connect, mirroring how
URL updates already worked. The MCP zod schema and the protocol
reference in the README are updated accordingly.
* feat(ui-web): expose CSMS credentials in set-supervision dialog
Surface the extended setSupervisionUrl procedure on the web UI: user
and password inputs next to the existing URL field, a client-side
guard that requires at least one field set, and a renamed form
header ("Set Supervision") that matches the broader scope. UIClient
passes the new fields through only when populated, so existing
URL-only callers are unaffected.
* fix(ui-web): serve index.html as SPA fallback so deep-linked routes survive reload
The built bundle is served by start.js via serve-static, which 404s on
any path that isn't a physical file — so refreshing a router route
like /add-charging-stations or /set-supervision-url/... produced
"Cannot GET ...". When serve-static gives up, fall back to sending
the bundled index.html with 200 OK so the SPA router can resolve the
route client-side. POST/PUT/etc. still fall through to finalhandler.
* [autofix.ci] apply automated fixes
* test(ui-web): update AddChargingStations tests for new fields
Bump checkbox count to 5 (appendCounter added) and cover baseName,
supervision credential pass-through and fixedName behaviour.
* fix: PR comments
* fix: PR comments
- server: setSupervisionUrl: url required, supervisionUser/supervisionPassword
independent (string updates incl. "" to clear, undefined preserves);
drop block-comment noise; align README and MCP schema descriptions
- ui: SetSupervisionUrl dialog: always send credentials verbatim — empty
fields clear stored credentials (frontend can't distinguish null
vs empty)
- ui: AddChargingStations dialog: replace inverted appendCounter with
fixedName mirroring the API field; label "(base name is full
station name)"; empty fields keep preserving template defaults
- server: Rename StationIdentity → ChargingStationNameTemplate (and the
parameter in getChargingStationId) so the type name reflects its
role as the building blocks of chargingStationId
- Tests updated to match the new semantics
* [autofix.ci] apply automated fixes
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
`template`: string,
`numberOfStations`: number,
`options?`: {
- `supervisionUrls?`: string | string[],
- `persistentConfiguration?`: boolean,
- `autoStart?`: boolean,
`autoRegister?`: boolean,
+ `autoStart?`: boolean,
+ `baseName?`: string,
`enableStatistics?`: boolean,
+ `fixedName?`: boolean,
+ `nameSuffix?`: string,
`ocppStrictCompliance?`: boolean,
- `stopTransactionsOnStopped?`: boolean
+ `persistentConfiguration?`: boolean,
+ `stopTransactionsOnStopped?`: boolean,
+ `supervisionPassword?`: string,
+ `supervisionUrls?`: string | string[],
+ `supervisionUser?`: string
}
}
`ProcedureName`: 'setSupervisionUrl'
`PDU`: {
`hashIds`: charging station unique identifier strings array (optional, default: all charging stations),
- `url`: string
- }
+ `url`: string,
+ `supervisionUser?`: string,
+ `supervisionPassword?`: string
+ }
+ `url` is required. `supervisionUser` and `supervisionPassword` are each optional and independent: a string (including `""`, which clears the field) updates the value; omitting the field preserves the existing value. Changes take effect on the next WebSocket (re)connect.
- Response:
`PDU`: {
}
/**
- * Updates the supervision server URL in configuration or station info.
+ * Updates the supervision server URL and optionally the CSMS basic auth credentials in configuration or station info.
* @param url - The new supervision server URL
+ * @param supervisionUser - The new CSMS basic auth user (optional; "" clears, undefined preserves)
+ * @param supervisionPassword - The new CSMS basic auth password (optional; "" clears, undefined preserves)
*/
- public setSupervisionUrl (url: string): void {
+ public setSupervisionUrl (
+ url: string,
+ supervisionUser?: string,
+ supervisionPassword?: string
+ ): void {
if (
this.stationInfo?.supervisionUrlOcppConfiguration === true &&
isNotEmptyString(this.stationInfo.supervisionUrlOcppKey)
this.configuredSupervisionUrl = this.getConfiguredSupervisionUrl()
this.saveStationInfo()
}
+ if (this.stationInfo != null && (supervisionUser != null || supervisionPassword != null)) {
+ if (supervisionUser != null) {
+ this.stationInfo.supervisionUser = supervisionUser
+ }
+ if (supervisionPassword != null) {
+ this.stationInfo.supervisionPassword = supervisionPassword
+ }
+ this.saveStationInfo()
+ this.emitChargingStationEvent(ChargingStationEvents.updated)
+ }
}
/** Starts the charging station, initializes connectors, and connects to the central server. */
}
private getStationInfo (options?: ChargingStationOptions): ChargingStationInfo {
- const stationInfoFromTemplate = this.getStationInfoFromTemplate()
+ const { stationInfo: stationInfoFromTemplate, stationTemplate } =
+ this.getStationInfoFromTemplate()
options?.persistentConfiguration != null &&
(stationInfoFromTemplate.stationInfoPersistentConfiguration = options.persistentConfiguration)
const stationInfoFromFile = this.getStationInfoFromFile(
} else {
stationInfo = stationInfoFromTemplate
stationInfoFromFile != null &&
- propagateSerialNumber(this.getTemplateFromFile(), stationInfoFromFile, stationInfo)
+ propagateSerialNumber(stationTemplate, stationInfoFromFile, stationInfo)
}
- return setChargingStationOptions(
+ stationInfo = setChargingStationOptions(
mergeDeepRight(Constants.DEFAULT_STATION_INFO as ChargingStationInfo, stationInfo),
options
)
+ stationInfo.chargingStationId = getChargingStationId(this.index, stationInfo)
+ stationInfo.hashId = getHashId(this.index, stationTemplate, stationInfo.chargingStationId)
+ return stationInfo
}
private getStationInfoFromFile (
return stationInfo
}
- private getStationInfoFromTemplate (): ChargingStationInfo {
+ private getStationInfoFromTemplate (): {
+ stationInfo: ChargingStationInfo
+ stationTemplate: ChargingStationTemplate
+ } {
const stationTemplate = this.getTemplateFromFile()
if (stationTemplate == null) {
const errorMsg = `Failed to read charging station template file ${this.templateFile}`
checkEvsesConfiguration(stationTemplate, this.logPrefix(), this.templateFile)
}
const stationInfo = stationTemplateToStationInfo(stationTemplate)
- stationInfo.hashId = getHashId(this.index, stationTemplate)
+ // hashId and chargingStationId are intentionally not set here. They are derived
+ // at the end of getStationInfo() so that any identity overrides coming from
+ // ChargingStationOptions (baseName, fixedName, nameSuffix) are honoured.
stationInfo.templateIndex = this.index
stationInfo.templateName = buildTemplateName(this.templateFile)
- stationInfo.chargingStationId = getChargingStationId(this.index, stationTemplate)
createSerialNumber(stationTemplate, stationInfo)
stationInfo.voltageOut = this.getVoltageOut(stationInfo)
if (isNotEmptyArray<number>(stationTemplate.power)) {
if (stationTemplate.resetTime != null) {
stationInfo.resetTime = secondsToMilliseconds(stationTemplate.resetTime)
}
- return stationInfo
+ return { stationInfo, stationTemplate }
}
private getTemplateFromFile (): ChargingStationTemplate | undefined {
return join(templateFileParsedPath.dir, templateFileParsedPath.name)
}
+export type ChargingStationNameTemplate = Pick<
+ ChargingStationTemplate,
+ 'baseName' | 'fixedName' | 'nameSuffix'
+>
+
export const getChargingStationId = (
index: number,
- stationTemplate: ChargingStationTemplate | undefined
+ nameTemplate: ChargingStationNameTemplate | undefined
): string => {
- if (stationTemplate == null) {
+ if (nameTemplate == null) {
return "Unknown 'chargingStationId'"
}
// In case of multiple instances: add instance index to charging station id
const instanceIndex = env.CF_INSTANCE_INDEX ?? 0
- const idSuffix = stationTemplate.nameSuffix ?? ''
+ const idSuffix = nameTemplate.nameSuffix ?? ''
const idStr = `000000000${index.toString()}`
- return stationTemplate.fixedName === true
- ? stationTemplate.baseName
- : `${stationTemplate.baseName}-${instanceIndex.toString()}${idStr.substring(
+ return nameTemplate.fixedName === true
+ ? nameTemplate.baseName
+ : `${nameTemplate.baseName}-${instanceIndex.toString()}${idStr.substring(
idStr.length - 4
)}${idSuffix}`
}
}
}
-export const getHashId = (index: number, stationTemplate: ChargingStationTemplate): string => {
+export const getHashId = (
+ index: number,
+ stationTemplate: ChargingStationTemplate,
+ chargingStationIdOverride?: string
+): string => {
const chargingStationInfo = {
chargePointModel: stationTemplate.chargePointModel,
chargePointVendor: stationTemplate.chargePointVendor,
}
return hash(
Constants.DEFAULT_HASH_ALGORITHM,
- `${JSON.stringify(chargingStationInfo)}${getChargingStationId(index, stationTemplate)}`,
+ `${JSON.stringify(chargingStationInfo)}${
+ chargingStationIdOverride ?? getChargingStationId(index, stationTemplate)
+ }`,
'hex'
)
}
if (options?.supervisionUrls != null) {
stationInfo.supervisionUrls = options.supervisionUrls
}
+ if (options?.supervisionUser != null) {
+ stationInfo.supervisionUser = options.supervisionUser
+ }
+ if (options?.supervisionPassword != null) {
+ stationInfo.supervisionPassword = options.supervisionPassword
+ }
if (options?.persistentConfiguration != null) {
stationInfo.stationInfoPersistentConfiguration = options.persistentConfiguration
stationInfo.ocppPersistentConfiguration = options.persistentConfiguration
if (options?.stopTransactionsOnStopped != null) {
stationInfo.stopTransactionsOnStopped = options.stopTransactionsOnStopped
}
+ if (options?.baseName != null) {
+ stationInfo.baseName = options.baseName
+ }
+ if (options?.fixedName != null) {
+ stationInfo.fixedName = options.fixedName
+ }
+ if (options?.nameSuffix != null) {
+ stationInfo.nameSuffix = options.nameSuffix
+ }
return stationInfo
}
`${this.chargingStation.logPrefix()} ${moduleName}.requestHandler: 'url' field is required`
)
}
- this.chargingStation.setSupervisionUrl(url)
+ const supervisionUser = requestPayload?.supervisionUser
+ const supervisionPassword = requestPayload?.supervisionPassword
+ this.chargingStation.setSupervisionUrl(
+ url,
+ typeof supervisionUser === 'string' ? supervisionUser : undefined,
+ typeof supervisionPassword === 'string' ? supervisionPassword : undefined
+ )
},
],
[
const chargingStationOptionsSchema = z.object({
autoRegister: z.boolean().optional().describe('Set stations as registered at boot notification'),
autoStart: z.boolean().optional().describe('Enable automatic start of added charging station'),
+ baseName: z
+ .string()
+ .optional()
+ .describe('Override the template base name used to derive the chargingStationId'),
enableStatistics: z.boolean().optional().describe('Enable charging station statistics'),
+ fixedName: z
+ .boolean()
+ .optional()
+ .describe('Use baseName verbatim as chargingStationId instead of appending index/suffix'),
+ nameSuffix: z
+ .string()
+ .optional()
+ .describe('Suffix appended to the derived chargingStationId (ignored when fixedName is true)'),
ocppStrictCompliance: z
.boolean()
.optional()
.boolean()
.optional()
.describe('Enable stop transactions on station stop'),
+ supervisionPassword: z
+ .string()
+ .optional()
+ .describe('CSMS basic auth password used on the supervision WebSocket'),
supervisionUrls: z
.union([z.url(), z.array(z.url())])
.optional()
.describe('OCPP server supervision URL(s)'),
+ supervisionUser: z
+ .string()
+ .optional()
+ .describe('CSMS basic auth user used on the supervision WebSocket'),
})
/** Maps ProcedureName to OCPP JSON Schema file base names per version */
[
ProcedureName.SET_SUPERVISION_URL,
{
- description: 'Set the OCPP server supervision URL for one or more charging stations',
+ description:
+ 'Set the OCPP server supervision URL for one or more charging stations. Optionally updates the CSMS basic auth credentials (empty string clears a credential, undefined preserves it).',
inputSchema: z.object({
hashIds,
+ supervisionPassword: z
+ .string()
+ .optional()
+ .describe('CSMS basic auth password (empty string clears)'),
+ supervisionUser: z
+ .string()
+ .optional()
+ .describe('CSMS basic auth user (empty string clears)'),
url: z.url().describe('The OCPP server supervision URL to set'),
}),
},
export interface ChargingStationOptions extends JsonObject {
autoRegister?: boolean
autoStart?: boolean
+ baseName?: string
enableStatistics?: boolean
+ fixedName?: boolean
+ nameSuffix?: string
ocppStrictCompliance?: boolean
persistentConfiguration?: boolean
stopTransactionsOnStopped?: boolean
+ supervisionPassword?: string
supervisionUrls?: string | string[]
+ supervisionUser?: string
}
export interface ChargingStationWorkerData extends WorkerData {
hasPendingReservations,
hasReservationExpired,
resetConnectorStatus,
+ setChargingStationOptions,
validateStationInfo,
} from '../../src/charging-station/Helpers.js'
import {
ChargingProfilePurposeType,
type ChargingStationConfiguration,
type ChargingStationInfo,
+ type ChargingStationOptions,
type ChargingStationTemplate,
type ConnectorStatus,
ConnectorStatusEnum,
)
})
+ await it('should return baseName verbatim when fixedName is true', () => {
+ const template = {
+ baseName: 'DYNAMIC-STATION',
+ fixedName: true,
+ } satisfies Partial<ChargingStationTemplate>
+ assert.strictEqual(
+ getChargingStationId(1, template as ChargingStationTemplate),
+ 'DYNAMIC-STATION'
+ )
+ })
+
+ await it('should append nameSuffix to the indexed id when fixedName is false', () => {
+ const template = {
+ baseName: 'CS',
+ fixedName: false,
+ nameSuffix: '-X',
+ } satisfies Partial<ChargingStationTemplate>
+ assert.strictEqual(getChargingStationId(7, template as ChargingStationTemplate), 'CS-00007-X')
+ })
+
+ await it('should derive charging station id from stationInfo identity fields', () => {
+ // stationInfo satisfies the ChargingStationNameTemplate shape since it inherits baseName / fixedName / nameSuffix from the template type.
+ const stationInfo = {
+ baseName: 'INFO-STATION',
+ fixedName: true,
+ } satisfies Partial<ChargingStationInfo>
+ assert.strictEqual(getChargingStationId(1, stationInfo as ChargingStationInfo), 'INFO-STATION')
+ })
+
+ await it('should honour chargingStationId override in getHashId', () => {
+ const hashWithoutOverride = getHashId(1, chargingStationTemplate)
+ const hashWithOverride = getHashId(1, chargingStationTemplate, 'OVERRIDDEN-ID')
+ assert.notStrictEqual(hashWithoutOverride, hashWithOverride)
+ // Passing the default-derived id explicitly must reproduce the default hash.
+ assert.strictEqual(
+ getHashId(1, chargingStationTemplate, getChargingStationId(1, chargingStationTemplate)),
+ hashWithoutOverride
+ )
+ })
+
+ await it('should produce distinct hash ids when identity options differ', () => {
+ const baseId = getChargingStationId(1, chargingStationTemplate)
+ const overriddenId = getChargingStationId(1, {
+ ...chargingStationTemplate,
+ baseName: 'OTHER',
+ })
+ assert.notStrictEqual(
+ getHashId(1, chargingStationTemplate, baseId),
+ getHashId(1, chargingStationTemplate, overriddenId)
+ )
+ })
+
+ await describe('setChargingStationOptions', async () => {
+ const buildStationInfo = (): ChargingStationInfo =>
+ ({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ hashId: 'placeholder',
+ templateIndex: 0,
+ templateName: 'test-template',
+ }) as ChargingStationInfo
+
+ await it('should return stationInfo unchanged when options are undefined', () => {
+ const stationInfo = buildStationInfo()
+ const before = { ...stationInfo }
+ const result = setChargingStationOptions(stationInfo)
+ assert.deepStrictEqual(result, before)
+ })
+
+ await it('should apply baseName override', () => {
+ const options: ChargingStationOptions = { baseName: 'DYNAMIC' }
+ const result = setChargingStationOptions(buildStationInfo(), options)
+ assert.strictEqual(result.baseName, 'DYNAMIC')
+ })
+
+ await it('should apply fixedName override', () => {
+ const options: ChargingStationOptions = { fixedName: true }
+ const result = setChargingStationOptions(buildStationInfo(), options)
+ assert.strictEqual(result.fixedName, true)
+ })
+
+ await it('should apply nameSuffix override', () => {
+ const options: ChargingStationOptions = { nameSuffix: '-SUFFIX' }
+ const result = setChargingStationOptions(buildStationInfo(), options)
+ assert.strictEqual(result.nameSuffix, '-SUFFIX')
+ })
+
+ await it('should apply supervisionUser override', () => {
+ const options: ChargingStationOptions = { supervisionUser: 'alice' }
+ const result = setChargingStationOptions(buildStationInfo(), options)
+ assert.strictEqual(result.supervisionUser, 'alice')
+ })
+
+ await it('should apply supervisionPassword override', () => {
+ const options: ChargingStationOptions = { supervisionPassword: 'secret' }
+ const result = setChargingStationOptions(buildStationInfo(), options)
+ assert.strictEqual(result.supervisionPassword, 'secret')
+ })
+
+ await it('should not overwrite stationInfo fields when option value is undefined', () => {
+ const stationInfo = buildStationInfo()
+ stationInfo.baseName = 'KEEP-ME'
+ stationInfo.supervisionUser = 'original'
+ const result = setChargingStationOptions(stationInfo, {
+ autoStart: true,
+ })
+ assert.strictEqual(result.baseName, 'KEEP-ME')
+ assert.strictEqual(result.supervisionUser, 'original')
+ assert.strictEqual(result.autoStart, true)
+ })
+ })
+
await it('should throw when stationInfo is missing', () => {
// Arrange
// For validation edge cases, we need to manually create invalid states
export interface ChargingStationOptions extends JsonObject {
autoRegister?: boolean
autoStart?: boolean
+ baseName?: string
enableStatistics?: boolean
+ fixedName?: boolean
+ nameSuffix?: string
ocppStrictCompliance?: boolean
persistentConfiguration?: boolean
stopTransactionsOnStopped?: boolean
+ supervisionPassword?: string
supervisionUrls?: string | string[]
+ supervisionUser?: string
}
export interface ConfigurationKey extends OCPPConfigurationKey {
>
<p>Template options overrides:</p>
<ul class="template-options">
+ <li>
+ Base name:
+ <input
+ id="base-name"
+ v-model.trim="state.baseName"
+ class="base-name"
+ name="base-name"
+ placeholder="<template value>"
+ type="text"
+ >
+ Fixed name (base name is full station name):
+ <input
+ v-model="state.fixedName"
+ false-value="false"
+ true-value="true"
+ type="checkbox"
+ >
+ </li>
<li>
Supervision url:
<input
type="url"
>
</li>
+ <li>
+ Supervision credentials:
+ <input
+ id="supervision-user"
+ v-model.trim="state.supervisionUser"
+ autocomplete="off"
+ class="supervision-user"
+ name="supervision-user"
+ placeholder="<username>"
+ type="text"
+ >
+ <input
+ id="supervision-password"
+ v-model="state.supervisionPassword"
+ class="supervision-password"
+ name="supervision-password"
+ placeholder="<password>"
+ type="password"
+ >
+ </li>
<li>
Auto start:
<input
const state = ref<{
autoStart: boolean
+ baseName: string
enableStatistics: boolean
+ fixedName: boolean
numberOfStations: number
ocppStrictCompliance: boolean
persistentConfiguration: boolean
renderTemplates: UUIDv4
+ supervisionPassword: string
supervisionUrl: string
+ supervisionUser: string
template: string
}>({
autoStart: false,
+ baseName: '',
enableStatistics: false,
+ fixedName: false,
numberOfStations: 1,
ocppStrictCompliance: true,
persistentConfiguration: true,
renderTemplates: randomUUID(),
+ supervisionPassword: '',
supervisionUrl: '',
+ supervisionUser: '',
template: '',
})
executeAction(
$uiClient.addChargingStations(state.value.template, state.value.numberOfStations, {
autoStart: convertToBoolean(state.value.autoStart),
+ baseName: state.value.baseName.length > 0 ? state.value.baseName : undefined,
enableStatistics: convertToBoolean(state.value.enableStatistics),
+ fixedName:
+ state.value.baseName.length > 0 ? convertToBoolean(state.value.fixedName) : undefined,
ocppStrictCompliance: convertToBoolean(state.value.ocppStrictCompliance),
persistentConfiguration: convertToBoolean(state.value.persistentConfiguration),
+ supervisionPassword:
+ state.value.supervisionPassword.length > 0 ? state.value.supervisionPassword : undefined,
supervisionUrls:
state.value.supervisionUrl.length > 0 ? state.value.supervisionUrl : undefined,
+ supervisionUser:
+ state.value.supervisionUser.length > 0 ? state.value.supervisionUser : undefined,
}),
'Charging stations successfully added',
'Error at adding charging stations',
text-align: center;
}
+.supervision-url,
+.base-name,
+.supervision-user,
+.supervision-password {
+ width: 100%;
+ max-width: 40rem;
+ text-align: left;
+}
+
.template-options {
- list-style: circle inside;
+ list-style: circle;
text-align: left;
}
</style>
Set Supervision Url
</h1>
<h2>{{ chargingStationId }}</h2>
- <p>Supervision Url:</p>
+ <p>Supervision url:</p>
<input
id="supervision-url"
v-model.trim="state.supervisionUrl"
placeholder="wss://"
type="url"
>
+ <p>Supervision credentials:</p>
+ <input
+ id="supervision-user"
+ v-model.trim="state.supervisionUser"
+ autocomplete="off"
+ class="supervision-user"
+ name="supervision-user"
+ placeholder="<username>"
+ type="text"
+ >
+ <input
+ id="supervision-password"
+ v-model="state.supervisionPassword"
+ class="supervision-password"
+ name="supervision-password"
+ placeholder="<password>"
+ type="password"
+ >
<br>
<Button
id="action-button"
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
+import { useToast } from 'vue-toast-notification'
import Button from '@/components/buttons/Button.vue'
import { resetToggleButtonState, ROUTE_NAMES, useExecuteAction, useUIClient } from '@/composables'
hashId: string
}>()
-const state = ref<{ supervisionUrl: string }>({
+const state = ref<{
+ supervisionPassword: string
+ supervisionUrl: string
+ supervisionUser: string
+}>({
+ supervisionPassword: '',
supervisionUrl: '',
+ supervisionUser: '',
})
const $uiClient = useUIClient()
const $router = useRouter()
+const $toast = useToast()
const executeAction = useExecuteAction()
const setSupervisionUrl = (): void => {
+ if (state.value.supervisionUrl.length === 0) {
+ $toast.error('Supervision url is required')
+ return
+ }
executeAction(
- $uiClient.setSupervisionUrl(props.hashId, state.value.supervisionUrl),
+ $uiClient.setSupervisionUrl(
+ props.hashId,
+ state.value.supervisionUrl,
+ state.value.supervisionUser,
+ state.value.supervisionPassword
+ ),
'Supervision url successfully set',
'Error at setting supervision url',
{
)
}
</script>
+
+<style scoped>
+.supervision-url,
+.supervision-user,
+.supervision-password {
+ width: 100%;
+ max-width: 40rem;
+ text-align: left;
+}
+</style>
})
}
- public async setSupervisionUrl (hashId: string, supervisionUrl: string): Promise<ResponsePayload> {
+ public async setSupervisionUrl (
+ hashId: string,
+ supervisionUrl: string,
+ supervisionUser?: string,
+ supervisionPassword?: string
+ ): Promise<ResponsePayload> {
return this.sendRequest(ProcedureName.SET_SUPERVISION_URL, {
hashIds: [hashId],
url: supervisionUrl,
+ ...(supervisionUser != null && { supervisionUser }),
+ ...(supervisionPassword != null && { supervisionPassword }),
})
}
import finalhandler from 'finalhandler'
+import { readFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { dirname, join } from 'node:path'
import { env } from 'node:process'
const PORT = isCFEnvironment ? Number.parseInt(env.PORT) : UI_DEV_SERVER_PORT
const uiPath = join(dirname(fileURLToPath(import.meta.url)), './dist')
+const indexHtml = readFileSync(join(uiPath, 'index.html'))
const serve = serveStatic(uiPath)
-const server = createServer((req, res) => serve(req, res, finalhandler(req, res)))
+const server = createServer((req, res) => {
+ serve(req, res, () => {
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
+ finalhandler(req, res)()
+ return
+ }
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
+ res.end(indexHtml)
+ })
+})
server.listen(PORT, () => console.info(`Web UI running at: http://localhost:${PORT}`))
it('should render option checkboxes', () => {
const wrapper = mountComponent()
const checkboxes = wrapper.findAll('input[type="checkbox"]')
- expect(checkboxes.length).toBe(4)
+ expect(checkboxes.length).toBe(5)
})
it('should pass supervision URL option when provided', async () => {
expect.objectContaining({ supervisionUrls: undefined })
)
})
+
+ it('should pass baseName and credentials when provided', async () => {
+ const wrapper = mountComponent()
+ await wrapper.find('#base-name').setValue('DEV-STATION')
+ await wrapper.find('#supervision-user').setValue('alice')
+ await wrapper.find('#supervision-password').setValue('s3cret')
+ await wrapper.find('button').trigger('click')
+ await flushPromises()
+ expect(mockClient.addChargingStations).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ expect.objectContaining({
+ baseName: 'DEV-STATION',
+ supervisionPassword: 's3cret',
+ supervisionUser: 'alice',
+ })
+ )
+ })
+
+ it('should pass fixedName: true when baseName is set and fixedName checkbox is checked', async () => {
+ const wrapper = mountComponent()
+ await wrapper.find('#base-name').setValue('DEV-STATION')
+ const checkboxes = wrapper.findAll('input[type="checkbox"]')
+ await checkboxes[0].setValue(true)
+ await wrapper.find('button').trigger('click')
+ await flushPromises()
+ expect(mockClient.addChargingStations).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ expect.objectContaining({ baseName: 'DEV-STATION', fixedName: true })
+ )
+ })
})
expect(wrapper.text()).toContain(TEST_STATION_ID)
})
- it('should render supervision URL input', () => {
+ it('should render supervision URL and credential inputs', () => {
const wrapper = mountComponent()
expect(wrapper.find('#supervision-url').exists()).toBe(true)
+ expect(wrapper.find('#supervision-user').exists()).toBe(true)
+ expect(wrapper.find('#supervision-password').exists()).toBe(true)
})
- it('should call setSupervisionUrl on button click', async () => {
+ it('should send empty credential strings when only url is set', async () => {
const wrapper = mountComponent()
- const input = wrapper.find('#supervision-url')
- await input.setValue('wss://new-server.com:9000')
+ await wrapper.find('#supervision-url').setValue('wss://new-server.com:9000')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(mockClient.setSupervisionUrl).toHaveBeenCalledWith(
TEST_HASH_ID,
- 'wss://new-server.com:9000'
+ 'wss://new-server.com:9000',
+ '',
+ ''
)
})
+ it('should call setSupervisionUrl with credentials when all fields are set', async () => {
+ const wrapper = mountComponent()
+ await wrapper.find('#supervision-url').setValue('wss://new-server.com:9000')
+ await wrapper.find('#supervision-user').setValue('alice')
+ await wrapper.find('#supervision-password').setValue('s3cret')
+ await wrapper.find('button').trigger('click')
+ await flushPromises()
+ expect(mockClient.setSupervisionUrl).toHaveBeenCalledWith(
+ TEST_HASH_ID,
+ 'wss://new-server.com:9000',
+ 'alice',
+ 's3cret'
+ )
+ })
+
+ it('should send empty password when only user is typed', async () => {
+ const wrapper = mountComponent()
+ await wrapper.find('#supervision-url').setValue('wss://new-server.com:9000')
+ await wrapper.find('#supervision-user').setValue('alice')
+ await wrapper.find('button').trigger('click')
+ await flushPromises()
+ expect(mockClient.setSupervisionUrl).toHaveBeenCalledWith(
+ TEST_HASH_ID,
+ 'wss://new-server.com:9000',
+ 'alice',
+ ''
+ )
+ })
+
+ it('should not call setSupervisionUrl when url is empty', async () => {
+ const wrapper = mountComponent()
+ await wrapper.find('#supervision-user').setValue('alice')
+ await wrapper.find('#supervision-password').setValue('s3cret')
+ await wrapper.find('button').trigger('click')
+ await flushPromises()
+ expect(mockClient.setSupervisionUrl).not.toHaveBeenCalled()
+ expect(toastMock.error).toHaveBeenCalled()
+ })
+
it('should navigate to charging-stations after submission', async () => {
const wrapper = mountComponent()
+ await wrapper.find('#supervision-url').setValue('wss://new-server.com:9000')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'charging-stations' })
it('should show error toast on failure', async () => {
const wrapper = mountComponent()
mockClient.setSupervisionUrl = vi.fn().mockRejectedValue(new Error('Network error'))
+ await wrapper.find('#supervision-url').setValue('wss://new-server.com:9000')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(toastMock.error).toHaveBeenCalled()
})
})
+ it('should send SET_SUPERVISION_URL with credentials when provided', async () => {
+ const url = 'ws://new-supervision:9001'
+ await client.setSupervisionUrl(TEST_HASH_ID, url, 'alice', 's3cret')
+ expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.SET_SUPERVISION_URL, {
+ hashIds: [TEST_HASH_ID],
+ supervisionPassword: 's3cret',
+ supervisionUser: 'alice',
+ url,
+ })
+ })
+
it('should send AUTHORIZE with hashIds and idTag', async () => {
await client.authorize(TEST_HASH_ID, TEST_ID_TAG)
expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.AUTHORIZE, {