From c9994a3576f300bffd6efce3a5c216a58010bb92 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 19:15:55 +0200 Subject: [PATCH] =?utf8?q?fix:=20resolve=20#1244=20=E2=80=94=20add=20per-c?= =?utf8?q?onnector=20maximum=20power=20support=20(#1843)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(charging-station): add per-connector maximum power support Add maximumPower field to ConnectorStatus representing the physical limitation of each connector cable/plug (thermal current rating). Per OCPP Device Model, AvailablePowerMaxLimit is defined at the Connector component level. The connector maximumPower acts as a hardware cap in the power computation pipeline alongside the station- level powerDivider sharing mechanism. - Add ConnectorStatus.maximumPower?: number (in W) - Initialize at boot via initializeConnectorsMaximumPower(): default is stationPower / staticConnectorCount (using static count, not dynamic powerDivider which can be 0 in shared mode at init) - Clamp in getConnectorMaximumAvailablePower as additional min() term - Use in getConnectorChargingProfilesLimit as primary cap (falls back to stationPower/powerDivider for backward compat) - Update 6 shared-mode templates with explicit maximumPower per connector (= station power for DC shared-bus stations) Resolves #1244 * chore(sandcastle): update validation and main scripts * chore: sync release-please manifests and sandcastle prompt * fix(charging-station): exclude index 0 from staticCount in getDefaultConnectorMaximumPower The staticCount calculation included EVSE 0 and connector 0, while runtime getPowerDivider excludes them. This caused connector hardware caps to be more restrictive than intended (e.g., stationPower/3 instead of stationPower/2 on a 2-EVSE station with EVSE 0 defined). * [autofix.ci] apply automated fixes * refactor(charging-station): add NaN guard to connectorHardwareMaximumPower in min() Align the connectorHardwareMaximumPower entry with the same null/NaN guard pattern used by all other entries in the min() call for consistency and defensive robustness. * docs: document per-connector maximumPower in template examples Add maximumPower field to Connectors and Evses section examples. Clarify powerSharedByConnectors behavior description. --------- Co-authored-by: Jérôme Benoit Co-authored-by: Jérôme Benoit Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .sandcastle/main.ts | 2 +- .sandcastle/plan-prompt.md | 4 +- .../strategies/implement/critic-prompt.md | 2 +- .sandcastle/validation.ts | 11 ++++- README.md | 4 +- .../abb-atg.station-template.json | 2 + .../abb.station-template.json | 2 + .../virtual-simple-atg.station-template.json | 3 ++ ...irtual-simple-signed.station-template.json | 3 ++ .../virtual-simple.station-template.json | 3 ++ .../virtual.station-template.json | 9 ++++ src/charging-station/ChargingStation.ts | 17 ++++++- src/charging-station/Helpers.ts | 49 +++++++++++++++++-- src/types/ConnectorStatus.ts | 1 + 14 files changed, 100 insertions(+), 12 deletions(-) diff --git a/.sandcastle/main.ts b/.sandcastle/main.ts index f5a21acf..bfed3f7b 100644 --- a/.sandcastle/main.ts +++ b/.sandcastle/main.ts @@ -84,7 +84,7 @@ if (tasks.length === 0) { if (outcome.status === 'rejected') { const reason: unknown = outcome.reason const msg = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason) - console.error(` ✗ #${tasks[i].id} failed: ${msg}`) + console.error(` ✗ #${tasks[i]?.id ?? String(i)} failed: ${msg}`) } } diff --git a/.sandcastle/plan-prompt.md b/.sandcastle/plan-prompt.md index 3108d244..547951de 100644 --- a/.sandcastle/plan-prompt.md +++ b/.sandcastle/plan-prompt.md @@ -26,7 +26,7 @@ Read `AGENTS.md` and `.serena/memories/project_overview`. - Classify the issue type: `bug-fix`, `feature`, or `refactor`. - Assess your confidence: `high` (clear scope, obvious approach), `medium` (some ambiguity), or `low` (unclear scope, multiple valid approaches). - Formulate a root cause hypothesis: what is broken or missing, and why. This is a hypothesis for the implementer to validate — not a directive. - - Define 2-4 acceptance criteria: concrete, verifiable conditions that must be true when the implementation is complete. Focus on observable behavior, not implementation details. + - Define 2-4 acceptance criteria: concrete, verifiable conditions that must be true when the implementation is complete. Focus on code structure, algorithmic and logic, not runtime behavior. 4. Output the plan in this exact format: @@ -47,7 +47,7 @@ Read `AGENTS.md` and `.serena/memories/project_overview`. ``` - Do not implement anything. Only produce the plan. -- Acceptance criteria must be testable by reading code or running tests — no subjective assessments. +- Acceptance criteria must be verifiable by static code inspection of the diff. - Root cause hypothesis should be specific (mention modules, patterns, or behaviors) — not a restatement of the issue title. ## Completion diff --git a/.sandcastle/strategies/implement/critic-prompt.md b/.sandcastle/strategies/implement/critic-prompt.md index 2caa8bbc..d7caf81d 100644 --- a/.sandcastle/strategies/implement/critic-prompt.md +++ b/.sandcastle/strategies/implement/critic-prompt.md @@ -12,7 +12,7 @@ Read `AGENTS.md`, `CONTRIBUTING.md` and `.serena/memories/code_style_conventions {{ACCEPTANCE_CRITERIA}} -If acceptance criteria are listed above, verify that the implementation satisfies each one. Report a HIGH finding for any criterion that is not met. Do NOT evaluate whether the actor followed a specific implementation approach — only whether the observable outcome matches the criteria. If no criteria are listed, skip this section. +If acceptance criteria are listed above, assess from the diff whether the implementation satisfies each one. Report a HIGH finding for any unmet criterion. Only judge observable outcomes, not implementation approach. If no criteria are listed, skip this section. ## Output Format diff --git a/.sandcastle/validation.ts b/.sandcastle/validation.ts index ab494493..ee4730ae 100644 --- a/.sandcastle/validation.ts +++ b/.sandcastle/validation.ts @@ -7,17 +7,26 @@ import { execFileAsync } from './utils.js' * Runs the full validation suite. * @param cwd - Working directory (worktree path). * @param spec - Optional task specification (used for logging). + * @param signal - Optional abort signal for cooperative cancellation. * @returns `true` if validation passed, `false` otherwise. */ -export async function runValidation (cwd: string, spec?: TaskSpec): Promise { +export async function runValidation ( + cwd: string, + spec?: TaskSpec, + signal?: AbortSignal +): Promise { try { await execFileAsync('sh', ['-c', VALIDATION_COMMAND], { cwd, maxBuffer: 8 * 1024 * 1024, + signal, timeout: VALIDATION_TIMEOUT_MS, }) return true } catch (err: unknown) { + if (signal?.aborted === true) { + throw err + } if (err && typeof err === 'object' && 'killed' in err && (err as { killed: boolean }).killed) { const label = spec ? `#${spec.id}` : 'mid-loop' console.warn(` ${label}: Validation timed out after ${String(VALIDATION_TIMEOUT_MS)}ms.`) diff --git a/README.md b/README.md index cef30888..83184f5d 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ But the modifications to test have to be done to the files in the build target d | firmwareVersionPattern | | Semantic versioning regular expression: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string | string | charging stations firmware version pattern | | firmwareVersion | | undefined | string | charging stations firmware version | | power | | | float \| float[] | charging stations maximum power value(s) | -| powerSharedByConnectors | true/false | false | boolean | charging stations power shared by its connectors | +| powerSharedByConnectors | true/false | false | boolean | charging stations power shared by its connectors. When true, any single connector can draw up to the full station power; when false, each connector is allocated an equal share | | powerUnit | W/kW | W | string | charging stations power unit | | currentOutType | AC/DC | AC | string | charging stations current out type | | voltageOut | | AC:230/DC:400 | integer | charging stations voltage out | @@ -330,6 +330,7 @@ type AutomaticTransactionGeneratorConfiguration = { "0": {}, "1": { "bootStatus": "Available", + "maximumPower": 50000, "MeterValues": [ ... { @@ -412,6 +413,7 @@ type AutomaticTransactionGeneratorConfiguration = { "Connectors": { "1": { "bootStatus": "Available", + "maximumPower": 22080, "MeterValues": [ ... { diff --git a/src/assets/station-templates/abb-atg.station-template.json b/src/assets/station-templates/abb-atg.station-template.json index 44e2435c..b9c9aaa6 100644 --- a/src/assets/station-templates/abb-atg.station-template.json +++ b/src/assets/station-templates/abb-atg.station-template.json @@ -67,6 +67,7 @@ "Connectors": { "0": {}, "1": { + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -94,6 +95,7 @@ ] }, "2": { + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", diff --git a/src/assets/station-templates/abb.station-template.json b/src/assets/station-templates/abb.station-template.json index 1930ef5e..9a12fc32 100644 --- a/src/assets/station-templates/abb.station-template.json +++ b/src/assets/station-templates/abb.station-template.json @@ -67,6 +67,7 @@ "Connectors": { "0": {}, "1": { + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -96,6 +97,7 @@ ] }, "2": { + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", diff --git a/src/assets/station-templates/virtual-simple-atg.station-template.json b/src/assets/station-templates/virtual-simple-atg.station-template.json index 67439657..8f527685 100644 --- a/src/assets/station-templates/virtual-simple-atg.station-template.json +++ b/src/assets/station-templates/virtual-simple-atg.station-template.json @@ -62,6 +62,7 @@ "0": {}, "1": { "bootStatus": "Available", + "maximumPower": 75000, "MeterValues": [ { "unit": "Percent", @@ -77,6 +78,7 @@ }, "2": { "bootStatus": "Preparing", + "maximumPower": 75000, "MeterValues": [ { "unit": "Percent", @@ -92,6 +94,7 @@ }, "3": { "bootStatus": "Faulted", + "maximumPower": 75000, "MeterValues": [ { "unit": "Percent", diff --git a/src/assets/station-templates/virtual-simple-signed.station-template.json b/src/assets/station-templates/virtual-simple-signed.station-template.json index f1bd28a4..2b50af93 100644 --- a/src/assets/station-templates/virtual-simple-signed.station-template.json +++ b/src/assets/station-templates/virtual-simple-signed.station-template.json @@ -102,6 +102,7 @@ "0": {}, "1": { "bootStatus": "Available", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -117,6 +118,7 @@ }, "2": { "bootStatus": "Preparing", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -132,6 +134,7 @@ }, "3": { "bootStatus": "Faulted", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", diff --git a/src/assets/station-templates/virtual-simple.station-template.json b/src/assets/station-templates/virtual-simple.station-template.json index 24fce690..c8a737dc 100644 --- a/src/assets/station-templates/virtual-simple.station-template.json +++ b/src/assets/station-templates/virtual-simple.station-template.json @@ -62,6 +62,7 @@ "0": {}, "1": { "bootStatus": "Available", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -77,6 +78,7 @@ }, "2": { "bootStatus": "Preparing", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -92,6 +94,7 @@ }, "3": { "bootStatus": "Faulted", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", diff --git a/src/assets/station-templates/virtual.station-template.json b/src/assets/station-templates/virtual.station-template.json index 3692355d..46f26238 100644 --- a/src/assets/station-templates/virtual.station-template.json +++ b/src/assets/station-templates/virtual.station-template.json @@ -62,6 +62,7 @@ "0": {}, "1": { "bootStatus": "Available", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -77,6 +78,7 @@ }, "2": { "bootStatus": "Preparing", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -92,6 +94,7 @@ }, "3": { "bootStatus": "SuspendedEVSE", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -107,6 +110,7 @@ }, "4": { "bootStatus": "SuspendedEV", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -122,6 +126,7 @@ }, "5": { "bootStatus": "Finishing", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -137,6 +142,7 @@ }, "6": { "bootStatus": "Reserved", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -152,6 +158,7 @@ }, "7": { "bootStatus": "Charging", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -167,6 +174,7 @@ }, "8": { "bootStatus": "Unavailable", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", @@ -182,6 +190,7 @@ }, "9": { "bootStatus": "Faulted", + "maximumPower": 50000, "MeterValues": [ { "unit": "Percent", diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 37f1c697..e9d86a7c 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -130,6 +130,7 @@ import { getChargingStationChargingProfilesLimit, getChargingStationId, getConnectorChargingProfilesLimit, + getDefaultConnectorMaximumPower, getDefaultVoltageOut, getHashId, getIdTagsFile, @@ -525,12 +526,16 @@ export class ChargingStation extends EventEmitter { return Number.POSITIVE_INFINITY } const connectorMaximumPower = maximumPower / (this.powerDivider ?? 1) + const connectorHardwareMaximumPower = this.getConnectorStatus(connectorId)?.maximumPower const chargingStationChargingProfilesLimit = (getChargingStationChargingProfilesLimit(this) ?? Number.POSITIVE_INFINITY) / (this.powerDivider ?? 1) const connectorChargingProfilesLimit = getConnectorChargingProfilesLimit(this, connectorId) return min( Number.isNaN(connectorMaximumPower) ? Number.POSITIVE_INFINITY : connectorMaximumPower, + connectorHardwareMaximumPower == null || Number.isNaN(connectorHardwareMaximumPower) + ? Number.POSITIVE_INFINITY + : connectorHardwareMaximumPower, connectorAmperageLimitationLimit == null || Number.isNaN(connectorAmperageLimitationLimit) ? Number.POSITIVE_INFINITY : connectorAmperageLimitationLimit, @@ -1897,7 +1902,11 @@ export class ChargingStation extends EventEmitter { ) this.connectors.set(connectorId, clone(connectorStatus)) } - initializeConnectorsMapStatus(this.connectors, this.logPrefix()) + initializeConnectorsMapStatus( + this.connectors, + this.logPrefix(), + getDefaultConnectorMaximumPower(stationTemplate) + ) this.saveConnectorsStatus() } else { logger.warn( @@ -2047,7 +2056,11 @@ export class ChargingStation extends EventEmitter { ), } this.evses.set(evseId, evseStatus) - initializeConnectorsMapStatus(evseStatus.connectors, this.logPrefix()) + initializeConnectorsMapStatus( + evseStatus.connectors, + this.logPrefix(), + getDefaultConnectorMaximumPower(stationTemplate) + ) } this.saveEvsesStatus() } else { diff --git a/src/charging-station/Helpers.ts b/src/charging-station/Helpers.ts index c3c98026..b92f58ab 100644 --- a/src/charging-station/Helpers.ts +++ b/src/charging-station/Helpers.ts @@ -44,6 +44,7 @@ import { CurrentType, type EvseTemplate, OCPPVersion, + PowerUnits, RecurrencyKindType, type Reservation, ReservationTerminationReason, @@ -290,6 +291,37 @@ export const getMaxNumberOfEvses = (evses: Record | undefi return isEmpty(evses) ? 0 : Object.keys(evses).length } +export const getDefaultConnectorMaximumPower = ( + stationTemplate: ChargingStationTemplate +): number | undefined => { + let maximumPower: number | undefined + if (isNotEmptyArray(stationTemplate.power)) { + const powerArrayRandomIndex = Math.floor(secureRandom() * stationTemplate.power.length) + maximumPower = + stationTemplate.powerUnit === PowerUnits.KILO_WATT + ? stationTemplate.power[powerArrayRandomIndex] * 1000 + : stationTemplate.power[powerArrayRandomIndex] + } else if (typeof stationTemplate.power === 'number') { + maximumPower = + stationTemplate.powerUnit === PowerUnits.KILO_WATT + ? stationTemplate.power * 1000 + : stationTemplate.power + } + if (maximumPower == null) { + return undefined + } + if (stationTemplate.powerSharedByConnectors === true) { + return maximumPower + } + const staticCount = + stationTemplate.Evses != null + ? // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + getMaxNumberOfEvses(stationTemplate.Evses) - (stationTemplate.Evses['0'] != null ? 1 : 0) + : getMaxNumberOfConnectors(stationTemplate.Connectors) - + (stationTemplate.Connectors?.['0'] != null ? 1 : 0) + return staticCount > 0 ? maximumPower / staticCount : undefined +} + const getMaxNumberOfConnectors = ( connectors: Record | undefined ): number => { @@ -502,7 +534,8 @@ export const buildConnectorsMap = ( export const initializeConnectorsMapStatus = ( connectors: Map, - logPrefix: string + logPrefix: string, + defaultMaximumPower?: number ): void => { for (const [connectorId, connectorStatus] of connectors) { if (connectorId > 0 && connectorStatus.transactionStarted === true) { @@ -525,7 +558,7 @@ export const initializeConnectorsMapStatus = ( connectorStatus.availability = AvailabilityType.Operative connectorStatus.chargingProfiles ??= [] } else if (connectorId > 0 && connectorStatus.transactionStarted == null) { - initializeConnectorStatus(connectorStatus) + initializeConnectorStatus(connectorStatus, defaultMaximumPower) } } } @@ -808,7 +841,9 @@ export const getConnectorChargingProfilesLimit = ( if (maximumPower == null) { return limit } - const connectorMaximumPower = maximumPower / (chargingStation.powerDivider ?? 1) + const connectorMaximumPower = + chargingStation.getConnectorStatus(connectorId)?.maximumPower ?? + maximumPower / (chargingStation.powerDivider ?? 1) if (limit > connectorMaximumPower) { logger.error( `${chargingStation.logPrefix()} ${moduleName}.getConnectorChargingProfilesLimit: Charging profile id ${getChargingProfileId(chargingProfilesLimit.chargingProfile)} limit ${limit.toString()} is greater than connector ${connectorId.toString()} maximum ${connectorMaximumPower.toString()}: %j`, @@ -957,7 +992,10 @@ const checkTemplateMaxConnectors = ( } } -const initializeConnectorStatus = (connectorStatus: ConnectorStatus): void => { +const initializeConnectorStatus = ( + connectorStatus: ConnectorStatus, + defaultMaximumPower?: number +): void => { connectorStatus.availability = AvailabilityType.Operative connectorStatus.idTagLocalAuthorized = false connectorStatus.idTagAuthorized = false @@ -966,6 +1004,9 @@ const initializeConnectorStatus = (connectorStatus: ConnectorStatus): void => { connectorStatus.energyActiveImportRegisterValue = 0 connectorStatus.transactionEnergyActiveImportRegisterValue = 0 connectorStatus.chargingProfiles ??= [] + if (defaultMaximumPower != null) { + connectorStatus.maximumPower ??= defaultMaximumPower + } } const warnDeprecatedTemplateKey = ( diff --git a/src/types/ConnectorStatus.ts b/src/types/ConnectorStatus.ts index de1e62aa..ff564a54 100644 --- a/src/types/ConnectorStatus.ts +++ b/src/types/ConnectorStatus.ts @@ -25,6 +25,7 @@ export interface ConnectorStatus { idTagLocalAuthorized?: boolean localAuthorizeIdTag?: string locked?: boolean + maximumPower?: number // In W MeterValues: SampledValueTemplate[] publicKeySentInTransaction?: boolean remoteStartId?: number -- 2.43.0