Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 116 additions & 64 deletions README.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions scripts/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ await build({
from: ['./src/assets/idtags!(-template)*.json'],
to: ['./assets'],
},
{
from: ['./src/assets/ev-profiles!(-template)*.json'],
to: ['./assets'],
},
{
from: ['./src/assets/json-schemas/**/*.json'],
to: ['./assets/json-schemas'],
Expand Down
46 changes: 46 additions & 0 deletions src/assets/ev-profiles-template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"profiles": [
{
"id": "city-ev-40kWh",
"weight": 3,
"batteryCapacityWh": 40000,
"maxPowerW": 11000,
"initialSocPercentMin": 15,
"initialSocPercentMax": 55,
"chargingCurve": [
{ "socPercent": 0, "powerFraction": 1.0 },
{ "socPercent": 75, "powerFraction": 1.0 },
{ "socPercent": 90, "powerFraction": 0.4 },
{ "socPercent": 100, "powerFraction": 0.08 }
]
},
{
"id": "long-range-ev-77kWh",
"weight": 2,
"batteryCapacityWh": 77000,
"maxPowerW": 22000,
"initialSocPercentMin": 10,
"initialSocPercentMax": 60,
"chargingCurve": [
{ "socPercent": 0, "powerFraction": 1.0 },
{ "socPercent": 70, "powerFraction": 0.85 },
{ "socPercent": 90, "powerFraction": 0.35 },
{ "socPercent": 100, "powerFraction": 0.05 }
]
},
{
"id": "dc-fast-ev-90kWh",
"weight": 1,
"batteryCapacityWh": 90000,
"maxPowerW": 150000,
"initialSocPercentMin": 10,
"initialSocPercentMax": 40,
"chargingCurve": [
{ "socPercent": 0, "powerFraction": 1.0 },
{ "socPercent": 50, "powerFraction": 0.85 },
{ "socPercent": 80, "powerFraction": 0.45 },
{ "socPercent": 100, "powerFraction": 0.05 }
]
}
]
}
119 changes: 119 additions & 0 deletions src/charging-station/ChargingStation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ import {
getConnectorChargingProfilesLimit,
getDefaultConnectorMaximumPower,
getDefaultVoltageOut,
getEvProfilesFile,
getHashId,
getIdTagsFile,
getMaxNumberOfConnectors,
Expand All @@ -147,6 +148,14 @@ import {
validateStationInfo,
} from './Helpers.js'
import { IdTagsCache } from './IdTagsCache.js'
import { disposeCoherentSessionRuntime } from './meter-values/CoherentMeterValuesGenerator.js'
import {
type CoherentSession,
createCoherentSession,
type EvProfilesFile,
loadEvProfilesFile,
resolveRootSeed,
} from './meter-values/index.js'
import {
buildBootNotificationRequest,
createOCPPServices,
Expand Down Expand Up @@ -206,6 +215,8 @@ export class ChargingStation extends EventEmitter {

private automaticTransactionGeneratorConfiguration?: AutomaticTransactionGeneratorConfiguration
private readonly chargingStationWorkerBroadcastChannel: ChargingStationWorkerBroadcastChannel
private coherentEvProfiles?: EvProfilesFile
private readonly coherentSessions: Map<number | string, CoherentSession>
private configurationFile!: string
private configurationFileHash!: string
private configuredSupervisionUrl!: URL
Expand Down Expand Up @@ -241,6 +252,7 @@ export class ChargingStation extends EventEmitter {
this.sharedLRUCache = SharedLRUCache.getInstance()
this.idTagsCache = IdTagsCache.getInstance()
this.chargingStationWorkerBroadcastChannel = new ChargingStationWorkerBroadcastChannel(this)
this.coherentSessions = new Map<number | string, CoherentSession>()

this.on(ChargingStationEvents.added, () => {
parentPort?.postMessage(buildAddedMessage(this))
Expand Down Expand Up @@ -302,6 +314,18 @@ export class ChargingStation extends EventEmitter {
}
}

/**
* Injects a pre-built coherent session directly into the session store.
* **Test seam only** — never call from production code. The `__` prefix
* marks this as a non-public API by convention; it is not currently
* enforced by a lint rule.
* @param transactionId - Transaction identifier.
* @param session - Pre-built session.
*/
public __injectCoherentSession (transactionId: number | string, session: CoherentSession): void {
this.coherentSessions.set(transactionId, session)
}

/**
* Adds a reservation to the specified connector.
* @param reservation - The reservation to add
Expand Down Expand Up @@ -345,6 +369,42 @@ export class ChargingStation extends EventEmitter {
}
}

/**
* Creates or returns the coherent MeterValues session for a transaction.
* Idempotent. Returns `undefined` when coherent mode is disabled or no
* valid EV profile file is loaded.
* @param transactionId - Transaction identifier from the CSMS.
* @param connectorId - Connector on which the transaction is running.
* @returns The active or newly-created session, or `undefined` when
* coherent mode is not usable.
*/
public createCoherentSession (
transactionId: number | string,
connectorId: number
): CoherentSession | undefined {
const existing = this.coherentSessions.get(transactionId)
if (existing != null) {
return existing
}
if (this.stationInfo?.coherentMeterValues !== true) {
return undefined
}
if (this.coherentEvProfiles == null || this.coherentEvProfiles.profiles.length === 0) {
return undefined
}
const rootSeed = resolveRootSeed(this.stationInfo)
const session = createCoherentSession(this, {
connectorId,
profiles: this.coherentEvProfiles.profiles,
rootSeed,
transactionId,
})
if (session != null) {
this.coherentSessions.set(transactionId, session)
}
return session
}

/**
* Deletes the charging station instance and optionally its persisted configuration.
* @param deleteConfiguration - Whether to delete the persisted configuration file
Expand Down Expand Up @@ -399,6 +459,21 @@ export class ChargingStation extends EventEmitter {
this.removeAllListeners()
}

/**
* Removes the coherent session for a transaction. Idempotent — safe to
* call from every reset/stop/disconnect path. Also disposes the module-scope
* per-session runtime state (voltage-noise PRNG closure).
* @param transactionId - Transaction identifier.
* @returns `true` when a session was removed, `false` otherwise.
*/
public destroyCoherentSession (transactionId: number | string | undefined): boolean {
if (transactionId == null) {
return false
}
disposeCoherentSessionRuntime(this.coherentSessions.get(transactionId))
return this.coherentSessions.delete(transactionId)
}

/**
* Emit a ChargingStation event only if there are listeners registered for it.
* This optimizes performance by avoiding unnecessary event emission.
Expand Down Expand Up @@ -458,6 +533,15 @@ export class ChargingStation extends EventEmitter {
return this.getConfigurationFromFile()?.automaticTransactionGeneratorStatuses
}

/**
* Retrieves the coherent session for a transaction, if any.
* @param transactionId - Transaction identifier.
* @returns The session or `undefined` when none exists.
*/
public getCoherentSession (transactionId: number | string): CoherentSession | undefined {
return this.coherentSessions.get(transactionId)
}

public getConnectionTimeout (): number {
if (getConfigurationKey(this, StandardParametersKey.ConnectionTimeOut) != null) {
return convertToInt(
Expand Down Expand Up @@ -1221,6 +1305,13 @@ export class ChargingStation extends EventEmitter {
this.sharedLRUCache.deleteChargingStationConfiguration(this.configurationFileHash)
this.emitChargingStationEvent(ChargingStationEvents.stopped)
} finally {
// Drop any coherent sessions still tracked at shutdown so a
// subsequent restart cannot resurrect stale state or leak
// module-scope runtime PRNG closures.
for (const session of this.coherentSessions.values()) {
disposeCoherentSessionRuntime(session)
}
this.coherentSessions.clear()
this.stopping = false
}
} else {
Expand Down Expand Up @@ -1814,6 +1905,7 @@ export class ChargingStation extends EventEmitter {
}
}
this.saveStationInfo()
this.initializeCoherentEvProfiles()
this.configuredSupervisionUrl = this.getConfiguredSupervisionUrl()
if (this.stationInfo.enableStatistics === true) {
this.performanceStatistics = PerformanceStatistics.getInstance(
Expand Down Expand Up @@ -1844,6 +1936,33 @@ export class ChargingStation extends EventEmitter {
}
}

/**
* Loads and validates the EV profile file when coherent MeterValues are
* enabled. Fail-soft: any error disables coherent mode for this station
* (createCoherentSession then becomes a no-op).
*/
private initializeCoherentEvProfiles (): void {
this.coherentEvProfiles = undefined
if (this.stationInfo?.coherentMeterValues !== true) {
return
}
const evProfilesFile = getEvProfilesFile(this.stationInfo)
if (evProfilesFile == null) {
logger.warn(
`${this.logPrefix()} ${moduleName}.initializeCoherentEvProfiles: coherentMeterValues=true but no evProfilesFile is configured, coherent MeterValues disabled`
)
return
}
const loaded = loadEvProfilesFile(evProfilesFile, this.logPrefix())
if (loaded == null) {
logger.warn(
`${this.logPrefix()} ${moduleName}.initializeCoherentEvProfiles: EV profiles could not be loaded, coherent MeterValues disabled`
)
return
}
this.coherentEvProfiles = loaded
}

private initializeConnectorsFromTemplate (stationTemplate: ChargingStationTemplate): void {
if (stationTemplate.Connectors == null && isEmpty(this.connectors)) {
const errorMsg = `No already defined connectors and charging station information from template ${this.templateFile} with no connectors configuration defined`
Expand Down
10 changes: 10 additions & 0 deletions src/charging-station/Helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,10 @@ export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefine
connectorStatus.transactionRemoteStarted = false
connectorStatus.transactionStarted = false
delete connectorStatus.transactionStart
// NOTE: `transactionId` is deleted here. Callers that need to identify
// the just-stopped transaction (e.g. `ChargingStation.destroyCoherentSession`)
// MUST snapshot `connectorStatus.transactionId` BEFORE invoking this
// function.
delete connectorStatus.transactionId
delete connectorStatus.transactionIdTag
delete connectorStatus.transactionGroupIdToken
Expand Down Expand Up @@ -812,6 +816,12 @@ export const getIdTagsFile = (stationInfo: ChargingStationInfo): string | undefi
: undefined
}

export const getEvProfilesFile = (stationInfo: ChargingStationInfo): string | undefined => {
return stationInfo.evProfilesFile != null
? join(dirname(fileURLToPath(import.meta.url)), 'assets', basename(stationInfo.evProfilesFile))
: undefined
}

export const waitChargingStationEvents = async (
emitter: EventEmitter,
event: ChargingStationWorkerMessageEvents,
Expand Down
3 changes: 3 additions & 0 deletions src/charging-station/TemplateSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,14 @@ const BaseTemplateSchema = z.looseObject({
chargePointModel: z.string().min(1),
chargePointSerialNumberPrefix: z.string().optional(),
chargePointVendor: z.string().min(1),
coherentMeterValues: z.boolean().optional(),
commandsSupport: CommandsSupportSchema.optional(),
Configuration: OcppConfigurationSchema.optional(),
Connectors: z.record(z.string().regex(/^\d+$/), ConnectorStatusSchema).optional(),
currentOutType: z.string().optional(),
customValueLimitationMeterValues: z.boolean().optional(),
enableStatistics: z.boolean().optional(),
evProfilesFile: z.string().optional(),
Evses: z.record(z.string().regex(/^\d+$/), EvseTemplateSchema).optional(),
firmwareUpgrade: FirmwareUpgradeSchema.optional(),
firmwareVersion: z.string().optional(),
Expand Down Expand Up @@ -215,6 +217,7 @@ const BaseTemplateSchema = z.looseObject({
powerSharedByConnectors: z.boolean().optional(),
powerUnit: z.string().optional(),
randomConnectors: z.boolean().optional(),
randomSeed: z.number().int().optional(),
reconnectExponentialDelay: z.boolean().optional(),
registrationMaxRetries: z.number().optional(),
remoteAuthorization: z.boolean().optional(),
Expand Down
1 change: 1 addition & 0 deletions src/charging-station/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export {
} from './Helpers.js'
export type { IBootstrap } from './IBootstrap.js'
export { IdTagsCache } from './IdTagsCache.js'
export type { CoherentSession } from './meter-values/index.js'
export { SharedLRUCache } from './SharedLRUCache.js'
export { applyMigration, coerceVersion, CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js'
export { StrictTemplateSchema, TemplateSchema } from './TemplateSchema.js'
Expand Down
Loading
Loading