From 3e6e8b44b6cdf55f3330e2e9904dc100827af257 Mon Sep 17 00:00:00 2001 From: andy-81 Date: Sat, 28 Mar 2026 16:18:07 +0000 Subject: [PATCH 01/16] Replacing ChargerClient.cs Replacing with updated code to respond to Octopus better as well as sending the details regularly like Octopus ask for. --- OCPPChargerSim/Simulator/ChargerClient.cs | 532 +++++++++++++++------- 1 file changed, 377 insertions(+), 155 deletions(-) diff --git a/OCPPChargerSim/Simulator/ChargerClient.cs b/OCPPChargerSim/Simulator/ChargerClient.cs index cb507b4..358d67b 100644 --- a/OCPPChargerSim/Simulator/ChargerClient.cs +++ b/OCPPChargerSim/Simulator/ChargerClient.cs @@ -43,6 +43,7 @@ public sealed class ChargerClient private const double TargetCurrentAmps = 20.0; private const double CurrentJitterAmps = 4.0; private const double FixedStateOfCharge = 21.0; + private const double GridFrequencyHz = 50.0; private readonly Uri _url; private readonly string _identity; @@ -66,6 +67,7 @@ public sealed class ChargerClient private double _meterAccumulatorWh; private DateTimeOffset _lastMeterSampleTimestamp = DateTimeOffset.MinValue; private CancellationTokenSource? _meterLoopCts; + private CancellationTokenSource? _clockAlignedLoopCts; private CancellationTokenSource? _manualSimulationCts; private CancellationTokenSource? _heartbeatLoopCts; private readonly object _manualLock = new(); @@ -127,6 +129,11 @@ public void SetLocalConfiguration(string key, string value) StartHeartbeatLoop(_runCancellationToken); } + if (string.Equals(key, "ClockAlignedDataInterval", StringComparison.OrdinalIgnoreCase) && _isRunning) + { + StartClockAlignedLoop(_runCancellationToken); + } + ConfigurationChanged?.Invoke(key, value); } @@ -283,7 +290,6 @@ public ChargerClient(Uri url, string identity, string authKey, ChargerIdentity c _configuration[$"Boot.{kvp.Key}"] = kvp.Value; } - _meterAccumulatorWh = LoadMeterAccumulator(); _meterValue = (int)Math.Round(_meterAccumulatorWh, MidpointRounding.AwayFromZero); _meterStartValue = _meterValue; @@ -335,6 +341,7 @@ public async Task RunAsync(CancellationToken cancellationToken) await EnsureRemoteStartConfigurationAsync(cancellationToken).ConfigureAwait(false); StartHeartbeatLoop(cancellationToken); + StartClockAlignedLoop(cancellationToken); await receiveTask.ConfigureAwait(false); } @@ -354,6 +361,7 @@ public async Task RunAsync(CancellationToken cancellationToken) { StopMeterValueLoop(); StopHeartbeatLoop(); + StopClockAlignedLoop(); var socket = _webSocket; _webSocket = null; @@ -604,48 +612,48 @@ private async Task HandleRemoteStartAsync(string uniqueId, JsonElement payload, await BeginChargingSequenceAsync(idTag, payload, uniqueId, StateInitiator.Remote, cancellationToken).ConfigureAwait(false); } -private async Task BeginChargingSequenceAsync(string idTag, JsonElement payload, string? callUniqueId, StateInitiator initiator, CancellationToken cancellationToken) -{ - _activeIdTag = idTag; - TransitionVehicleState("Preparing", initiator); - _logger.Info($"Vehicle state updated to: {_vehicle.State}"); - - if (!string.IsNullOrEmpty(callUniqueId)) + private async Task BeginChargingSequenceAsync(string idTag, JsonElement payload, string? callUniqueId, StateInitiator initiator, CancellationToken cancellationToken) { - await SendCallResultAsync(callUniqueId!, new Dictionary + _activeIdTag = idTag; + TransitionVehicleState("Preparing", initiator); + _logger.Info($"Vehicle state updated to: {_vehicle.State}"); + + if (!string.IsNullOrEmpty(callUniqueId)) { - ["status"] = "Accepted", - }, cancellationToken).ConfigureAwait(false); - } + await SendCallResultAsync(callUniqueId!, new Dictionary + { + ["status"] = "Accepted", + }, cancellationToken).ConfigureAwait(false); + } - await SendStatusNotificationAsync("Preparing", cancellationToken, TimeSpan.FromSeconds(5), waitForResponse: false).ConfigureAwait(false); + await SendStatusNotificationAsync("Preparing", cancellationToken, TimeSpan.FromSeconds(5), waitForResponse: false).ConfigureAwait(false); - var started = await SendStartTransactionAsync(idTag, payload, cancellationToken).ConfigureAwait(false); - if (!started) - { - TransitionVehicleState("SuspendedEV", initiator); - _activeIdTag = null; - await SendStatusNotificationAsync("SuspendedEV", cancellationToken, TimeSpan.FromSeconds(5)).ConfigureAwait(false); - return; - } + var started = await SendStartTransactionAsync(idTag, payload, cancellationToken).ConfigureAwait(false); + if (!started) + { + TransitionVehicleState("SuspendedEV", initiator); + _activeIdTag = null; + await SendStatusNotificationAsync("SuspendedEV", cancellationToken, TimeSpan.FromSeconds(5)).ConfigureAwait(false); + return; + } - try - { - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - return; - } + try + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } - TransitionVehicleState("Charging", initiator); - _logger.Info($"Vehicle state updated to: {_vehicle.State}"); + TransitionVehicleState("Charging", initiator); + _logger.Info($"Vehicle state updated to: {_vehicle.State}"); - await SendStatusNotificationAsync("Charging", cancellationToken, TimeSpan.FromSeconds(5), waitForResponse: false).ConfigureAwait(false); + await SendStatusNotificationAsync("Charging", cancellationToken, TimeSpan.FromSeconds(5), waitForResponse: false).ConfigureAwait(false); - StartMeterValueLoop(cancellationToken); - await SendMeterValuesAsync(cancellationToken).ConfigureAwait(false); -} + StartMeterValueLoop(cancellationToken); + await SendMeterValuesAsync(cancellationToken).ConfigureAwait(false); + } private async Task SendStartTransactionAsync(string idTag, JsonElement payload, CancellationToken cancellationToken) { @@ -770,6 +778,143 @@ private void StopMeterValueLoop() } } + // --------------------------------------------------------------------------- + // Clock-aligned MeterValues loop + // Fires on clock boundaries defined by ClockAlignedDataInterval (seconds). + // Sends the measurands listed in MeterValuesAlignedData. + // --------------------------------------------------------------------------- + + private void StartClockAlignedLoop(CancellationToken parentToken) + { + StopClockAlignedLoop(); + + var interval = GetClockAlignedInterval(); + if (interval <= TimeSpan.Zero) + { + return; + } + + var linked = CancellationTokenSource.CreateLinkedTokenSource(parentToken); + _clockAlignedLoopCts = linked; + var loopToken = linked.Token; + + _ = Task.Run(async () => + { + try + { + while (!loopToken.IsCancellationRequested) + { + var delay = TimeUntilNextClockBoundary(interval); + await Task.Delay(delay, loopToken).ConfigureAwait(false); + if (loopToken.IsCancellationRequested) + { + break; + } + + await SendClockAlignedMeterValuesAsync(loopToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (loopToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + _logger.Error(ex, "Clock-aligned MeterValues loop failed"); + } + }, CancellationToken.None); + } + + private void StopClockAlignedLoop() + { + if (_clockAlignedLoopCts is null) + { + return; + } + + try + { + _clockAlignedLoopCts.Cancel(); + } + catch (ObjectDisposedException) + { + } + finally + { + _clockAlignedLoopCts.Dispose(); + _clockAlignedLoopCts = null; + } + } + + /// + /// Returns the delay until the next clock-aligned boundary. + /// For example, with interval=900 (15 min) the boundaries are 00:00, 00:15, 00:30, 00:45. + /// + private static TimeSpan TimeUntilNextClockBoundary(TimeSpan interval) + { + var now = DateTimeOffset.UtcNow; + var secondsInDay = (long)now.TimeOfDay.TotalSeconds; + var intervalSeconds = (long)interval.TotalSeconds; + var secondsUntilNext = intervalSeconds - (secondsInDay % intervalSeconds); + if (secondsUntilNext == 0) + { + secondsUntilNext = intervalSeconds; + } + + return TimeSpan.FromSeconds(secondsUntilNext); + } + + /// + /// Sends a clock-aligned MeterValues message with the measurands listed + /// in MeterValuesAlignedData, using Sample.Clock context. + /// + private async Task SendClockAlignedMeterValuesAsync(CancellationToken cancellationToken) + { + var measurands = GetAlignedMeasurands(); + if (measurands.Count == 0) + { + return; + } + + var uniqueId = GenerateUniqueId(); + var tcs = RegisterCall(uniqueId); + + var sample = LatestSample; + var sampledValues = BuildSampledValues(measurands, sample, "Sample.Clock"); + + var payload = new Dictionary + { + ["connectorId"] = _connectorId, + ["meterValue"] = new object[] + { + new Dictionary + { + ["timestamp"] = DateTimeOffset.UtcNow.ToString("O"), + ["sampledValue"] = sampledValues, + }, + }, + }; + + if (_activeTransactionId.HasValue) + { + payload["transactionId"] = _activeTransactionId.Value; + } + + await SendCallAsync(uniqueId, "MeterValues", payload, cancellationToken).ConfigureAwait(false); + + try + { + var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(35), cancellationToken)); + if (completed != tcs.Task) + { + _logger.Info("Clock-aligned MeterValues response timed out"); + } + } + finally + { + _pendingCalls.TryRemove(uniqueId, out _); + } + } + private void StartHeartbeatLoop(CancellationToken parentToken) { StopHeartbeatLoop(); @@ -879,14 +1024,12 @@ private async Task SendMeterValuesAsync(CancellationToken cancellationToken) _meterAccumulatorWh += incrementWh; _meterValue = (int)Math.Round(_meterAccumulatorWh); var energyWhValue = Math.Round(_meterAccumulatorWh, 0, MidpointRounding.AwayFromZero); - var socValue = FixedStateOfCharge; - var energy = energyWhValue.ToString(CultureInfo.InvariantCulture); - var power = powerKwValue.ToString("0.0", CultureInfo.InvariantCulture); - string? soc = null; - if (_supportSoC) - { - soc = socValue.ToString("0.0", CultureInfo.InvariantCulture); - } + + var sample = new MeterSample(_meterAccumulatorWh, powerKwValue, currentAmps, _supportSoC ? FixedStateOfCharge : -1, now); + + // Build sampled values from the configured MeterValuesSampledData list + var measurands = GetSampledMeasurands(); + var sampledValues = BuildSampledValues(measurands, sample, "Sample.Periodic"); var payload = new Dictionary { @@ -896,49 +1039,12 @@ private async Task SendMeterValuesAsync(CancellationToken cancellationToken) { new Dictionary { - ["timestamp"] = DateTimeOffset.UtcNow.ToString("O"), - ["sampledValue"] = new object[] - { - new Dictionary - { - ["value"] = energy, - ["measurand"] = "Energy.Active.Import.Register", - ["unit"] = "Wh", - ["context"] = "Sample.Periodic", - }, - new Dictionary - { - ["value"] = power, - ["measurand"] = "Power.Active.Import", - ["unit"] = "kW", - ["context"] = "Sample.Periodic", - }, - }, + ["timestamp"] = now.ToString("O"), + ["sampledValue"] = sampledValues, }, }, }; - if (_supportSoC && - payload.TryGetValue("meterValue", out var meterValueObj) && - meterValueObj is object[] meterArray && - meterArray.Length > 0 && - meterArray[0] is Dictionary meterEntryCandidate && - meterEntryCandidate.TryGetValue("sampledValue", out var sampledObj) && - sampledObj is object[] sampledValues) - { - var meterEntry = meterEntryCandidate; - var extended = new object[sampledValues.Length + 1]; - Array.Copy(sampledValues, extended, sampledValues.Length); - extended[^1] = new Dictionary - { - ["value"] = soc!, - ["measurand"] = "SoC", - ["unit"] = "Percent", - ["context"] = "Sample.Periodic", - }; - meterEntry["sampledValue"] = extended; - } - await SendCallAsync(uniqueId, "MeterValues", payload, cancellationToken).ConfigureAwait(false); try @@ -954,7 +1060,7 @@ meterArray[0] is Dictionary meterEntryCandidate && _pendingCalls.TryRemove(uniqueId, out _); } - PublishSample(new MeterSample(_meterAccumulatorWh, powerKwValue, currentAmps, _supportSoC ? socValue : -1, DateTimeOffset.UtcNow)); + PublishSample(sample); PersistMeterAccumulator(); } @@ -1031,6 +1137,11 @@ private async Task HandleChangeConfigurationAsync(string uniqueId, JsonElement p StartHeartbeatLoop(cancellationToken); } + if (string.Equals(key, "ClockAlignedDataInterval", StringComparison.OrdinalIgnoreCase)) + { + StartClockAlignedLoop(cancellationToken); + } + await SendCallResultAsync(uniqueId, new Dictionary { ["status"] = "Accepted", @@ -1171,7 +1282,6 @@ private async Task HandleRemoteStopTransactionAsync(string uniqueId, JsonElement await SendStatusNotificationAsync("SuspendedEV", cancellationToken, TimeSpan.FromSeconds(5)).ConfigureAwait(false); await stopTask.ConfigureAwait(false); - } private async Task SendStopTransactionAsync(string reason, CancellationToken cancellationToken) @@ -1315,6 +1425,27 @@ private async Task HandleTriggerMessageAsync(string uniqueId, JsonElement payloa return; } + if (string.Equals(requestedMessage, "MeterValues", StringComparison.OrdinalIgnoreCase)) + { + await SendCallResultAsync(uniqueId, new Dictionary + { + ["status"] = "Accepted", + }, cancellationToken).ConfigureAwait(false); + + _ = Task.Run(async () => + { + try + { + await SendBootMeterValuesAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to send triggered MeterValues"); + } + }, CancellationToken.None); + return; + } + await SendCallResultAsync(uniqueId, new Dictionary { ["status"] = "NotImplemented", @@ -1530,38 +1661,38 @@ private void PublishSample(MeterSample sample) MeterSampled?.Invoke(sample); } -private void UpdateLocalVehicleState(string status, StateInitiator initiator) -{ - var normalized = status.Trim(); - - switch (normalized) + private void UpdateLocalVehicleState(string status, StateInitiator initiator) { - case "Available": - StopMeterValueLoop(); - TransitionVehicleState("Available", initiator); - break; - case "Charging": - TransitionVehicleState("Charging", initiator); - break; - case "Preparing": - TransitionVehicleState("Preparing", initiator); - break; - case "SuspendedEV": - StopMeterValueLoop(); - TransitionVehicleState("SuspendedEV", initiator); - break; - case "Finishing": - TransitionVehicleState("Finishing", initiator); - break; - case "Unavailable": - StopMeterValueLoop(); - TransitionVehicleState("Unavailable", initiator); - break; - default: - TransitionVehicleState(normalized, initiator); - break; + var normalized = status.Trim(); + + switch (normalized) + { + case "Available": + StopMeterValueLoop(); + TransitionVehicleState("Available", initiator); + break; + case "Charging": + TransitionVehicleState("Charging", initiator); + break; + case "Preparing": + TransitionVehicleState("Preparing", initiator); + break; + case "SuspendedEV": + StopMeterValueLoop(); + TransitionVehicleState("SuspendedEV", initiator); + break; + case "Finishing": + TransitionVehicleState("Finishing", initiator); + break; + case "Unavailable": + StopMeterValueLoop(); + TransitionVehicleState("Unavailable", initiator); + break; + default: + TransitionVehicleState(normalized, initiator); + break; + } } -} private string GetFallbackIdTag() { @@ -1617,6 +1748,22 @@ private TimeSpan GetMeterSampleInterval() return TimeSpan.FromSeconds(15); } + private TimeSpan GetClockAlignedInterval() + { + string? configured; + lock (_configuration) + { + _configuration.TryGetValue("ClockAlignedDataInterval", out configured); + } + + if (configured is not null && int.TryParse(configured, out var seconds) && seconds > 0) + { + return TimeSpan.FromSeconds(seconds); + } + + return TimeSpan.Zero; + } + private TimeSpan GetHeartbeatInterval() { string? configured; @@ -1633,6 +1780,115 @@ private TimeSpan GetHeartbeatInterval() return TimeSpan.FromSeconds(60); } + // --------------------------------------------------------------------------- + // Measurand helpers + // --------------------------------------------------------------------------- + + /// + /// Returns the list of measurands from MeterValuesSampledData config, + /// defaulting to Energy + Power if empty. + /// + private List GetSampledMeasurands() + { + string? configured; + lock (_configuration) + { + _configuration.TryGetValue("MeterValuesSampledData", out configured); + } + + return ParseMeasurandList(configured, new[] { "Energy.Active.Import.Register", "Power.Active.Import" }); + } + + /// + /// Returns the list of measurands from MeterValuesAlignedData config. + /// + private List GetAlignedMeasurands() + { + string? configured; + lock (_configuration) + { + _configuration.TryGetValue("MeterValuesAlignedData", out configured); + } + + return ParseMeasurandList(configured, Array.Empty()); + } + + private static List ParseMeasurandList(string? csv, IEnumerable defaults) + { + if (string.IsNullOrWhiteSpace(csv)) + { + return new List(defaults); + } + + var result = new List(); + foreach (var part in csv.Split(',')) + { + var trimmed = part.Trim(); + if (!string.IsNullOrEmpty(trimmed)) + { + result.Add(trimmed); + } + } + + return result.Count > 0 ? result : new List(defaults); + } + + /// + /// Builds a sampledValue array for the given measurand list and current meter state. + /// Measurands not supported by this charger (e.g. V2G export, Voltage) are reported + /// as zero so Octopus receives a complete, parseable payload. + /// + private object[] BuildSampledValues(IEnumerable measurands, MeterSample sample, string context) + { + var result = new List(); + foreach (var measurand in measurands) + { + var entry = BuildSingleMeasurand(measurand, sample, context); + if (entry is not null) + { + result.Add(entry); + } + } + + return result.ToArray(); + } + + private Dictionary? BuildSingleMeasurand(string measurand, MeterSample sample, string context) + { + // Map each measurand to its value and unit. + // For measurands this charger does not support (V2G export, Voltage, Frequency) + // we return 0 so Octopus knows what the charger is reporting. + return measurand.ToUpperInvariant() switch + { + "ENERGY.ACTIVE.IMPORT.REGISTER" => MeasurandEntry(measurand, sample.EnergyWh.ToString("0", CultureInfo.InvariantCulture), "Wh", context), + "POWER.ACTIVE.IMPORT" => MeasurandEntry(measurand, sample.PowerKw.ToString("0.0", CultureInfo.InvariantCulture), "kW", context), + "CURRENT.IMPORT" => MeasurandEntry(measurand, sample.CurrentAmps.ToString("0.0", CultureInfo.InvariantCulture), "A", context), + "CURRENT.OFFERED" => MeasurandEntry(measurand, sample.CurrentAmps.ToString("0.0", CultureInfo.InvariantCulture), "A", context), + "POWER.OFFERED" => MeasurandEntry(measurand, sample.PowerKw.ToString("0.0", CultureInfo.InvariantCulture), "kW", context), + "FREQUENCY" => MeasurandEntry(measurand, GridFrequencyHz.ToString("0.0", CultureInfo.InvariantCulture), "Hz", context), + "SOC" when _supportSoC && sample.StateOfCharge >= 0 => MeasurandEntry(measurand, sample.StateOfCharge.ToString("0.0", CultureInfo.InvariantCulture), "Percent", context), + "SOC" => MeasurandEntry(measurand, "0.0", "Percent", context), + // V2G / export measurands — this charger does not support V2G + "ENERGY.ACTIVE.EXPORT.REGISTER" => MeasurandEntry(measurand, "0", "Wh", context), + "POWER.ACTIVE.EXPORT" => MeasurandEntry(measurand, "0.0", "kW", context), + "CURRENT.EXPORT" => MeasurandEntry(measurand, "0.0", "A", context), + "VOLTAGE" => MeasurandEntry(measurand, NominalVoltage.ToString("0.0", CultureInfo.InvariantCulture), "V", context), + // Unknown measurands are silently skipped rather than sending garbage data + _ => null, + }; + } + + private static Dictionary MeasurandEntry(string measurand, string value, string unit, string context) + { + return new Dictionary + { + ["value"] = value, + ["measurand"] = measurand, + ["unit"] = unit, + ["context"] = context, + }; + } + private static bool TryGetString(JsonElement element, string propertyName, out string value) { if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String) @@ -1664,46 +1920,12 @@ private async Task SendBootMeterValuesAsync(CancellationToken cancellationToken) var sample = LatestSample; var energyValue = sample.EnergyWh > 0 ? sample.EnergyWh : _meterAccumulatorWh; - var powerValue = sample.PowerKw; - var currentValue = sample.CurrentAmps; - - var sampledValues = new List> - { - new() - { - ["value"] = energyValue.ToString("0", CultureInfo.InvariantCulture), - ["measurand"] = "Energy.Active.Import.Register", - ["unit"] = "Wh", - ["context"] = "Sample.Clock", - }, - }; - - sampledValues.Add(new Dictionary - { - ["value"] = powerValue.ToString("0.0", CultureInfo.InvariantCulture), - ["measurand"] = "Power.Active.Import", - ["unit"] = "kW", - ["context"] = "Sample.Clock", - }); - - sampledValues.Add(new Dictionary - { - ["value"] = currentValue.ToString("0.0", CultureInfo.InvariantCulture), - ["measurand"] = "Current.Import", - ["unit"] = "A", - ["context"] = "Sample.Clock", - }); + var bootSample = new MeterSample(energyValue, sample.PowerKw, sample.CurrentAmps, + _supportSoC ? FixedStateOfCharge : -1, DateTimeOffset.UtcNow); - if (_supportSoC) - { - sampledValues.Add(new Dictionary - { - ["value"] = FixedStateOfCharge.ToString("0.0", CultureInfo.InvariantCulture), - ["measurand"] = "SoC", - ["unit"] = "Percent", - ["context"] = "Sample.Clock", - }); - } + // At boot we report the full set of sampled measurands so Octopus can initialise all channels + var measurands = GetSampledMeasurands(); + var sampledValues = BuildSampledValues(measurands, bootSample, "Sample.Clock"); var payload = new Dictionary { @@ -1713,7 +1935,7 @@ private async Task SendBootMeterValuesAsync(CancellationToken cancellationToken) new Dictionary { ["timestamp"] = DateTimeOffset.UtcNow.ToString("O"), - ["sampledValue"] = sampledValues.ToArray(), + ["sampledValue"] = sampledValues, }, }, }; @@ -1738,7 +1960,7 @@ private async Task SendBootMeterValuesAsync(CancellationToken cancellationToken) _pendingCalls.TryRemove(uniqueId, out _); } - PublishSample(new MeterSample(energyValue, powerValue, currentValue, _supportSoC ? FixedStateOfCharge : -1, DateTimeOffset.UtcNow)); + PublishSample(bootSample); } private static string GenerateUniqueId() From 001a1d477c9ae27917f15415c7e57cbb303da3de Mon Sep 17 00:00:00 2001 From: andy-81 Date: Sat, 28 Mar 2026 17:04:07 +0000 Subject: [PATCH 02/16] Updating ChargerClient to receive data from HA --- OCPPChargerSim/Simulator/ChargerClient.cs | 96 +++++++++++++++++++---- 1 file changed, 81 insertions(+), 15 deletions(-) diff --git a/OCPPChargerSim/Simulator/ChargerClient.cs b/OCPPChargerSim/Simulator/ChargerClient.cs index 358d67b..5658fa9 100644 --- a/OCPPChargerSim/Simulator/ChargerClient.cs +++ b/OCPPChargerSim/Simulator/ChargerClient.cs @@ -75,6 +75,7 @@ public sealed class ChargerClient private readonly bool _supportSoC; private readonly bool _heartbeatEnabled; private readonly string _meterStateFilePath; + private Func? _externalMeterValuesProvider; private CancellationToken _runCancellationToken; private bool _isRunning; @@ -112,6 +113,15 @@ public IReadOnlyDictionary ConfigurationSnapshot public MeterSample LatestSample { get; private set; } = MeterSample.Empty; + /// + /// Wires up a delegate that the client calls each time it builds a MeterValues payload. + /// Return null from the delegate to fall back to simulated values. + /// + public void SetExternalMeterValuesProvider(Func provider) + { + _externalMeterValuesProvider = provider; + } + public void SetLocalConfiguration(string key, string value) { if (string.IsNullOrWhiteSpace(key)) @@ -879,6 +889,8 @@ private async Task SendClockAlignedMeterValuesAsync(CancellationToken cancellati var tcs = RegisterCall(uniqueId); var sample = LatestSample; + // Overlay with real values from Home Assistant if available + sample = ApplyExternalValues(sample); var sampledValues = BuildSampledValues(measurands, sample, "Sample.Clock"); var payload = new Dictionary @@ -1027,6 +1039,9 @@ private async Task SendMeterValuesAsync(CancellationToken cancellationToken) var sample = new MeterSample(_meterAccumulatorWh, powerKwValue, currentAmps, _supportSoC ? FixedStateOfCharge : -1, now); + // Overlay with real values from Home Assistant if available + sample = ApplyExternalValues(sample); + // Build sampled values from the configured MeterValuesSampledData list var measurands = GetSampledMeasurands(); var sampledValues = BuildSampledValues(measurands, sample, "Sample.Periodic"); @@ -1833,17 +1848,49 @@ private static List ParseMeasurandList(string? csv, IEnumerable return result.Count > 0 ? result : new List(defaults); } + /// + /// Overlays the given sample with any real values from Home Assistant. + /// Fields that were not provided by the external source keep the simulated value. + /// + private MeterSample ApplyExternalValues(MeterSample sample) + { + var ext = _externalMeterValuesProvider?.Invoke(); + if (ext is null) + { + return sample; + } + + // Update the accumulator so energy stays consistent on the next simulated tick + if (ext.EnergyWhImport.HasValue) + { + _meterAccumulatorWh = ext.EnergyWhImport.Value; + _meterValue = (int)Math.Round(_meterAccumulatorWh); + } + + return new MeterSample( + ext.EnergyWhImport ?? sample.EnergyWh, + ext.PowerKwImport ?? sample.PowerKw, + ext.CurrentAmpsOffered ?? sample.CurrentAmps, + ext.StateOfChargePercent ?? sample.StateOfCharge, + sample.Timestamp); + } + + /// + /// Holds supplementary external values used when building measurand entries + /// that aren't covered by the MeterSample struct (Frequency, Power.Offered, etc.). + /// + private ExternalMeterValues? GetCurrentExternalValues() => _externalMeterValuesProvider?.Invoke(); + /// /// Builds a sampledValue array for the given measurand list and current meter state. - /// Measurands not supported by this charger (e.g. V2G export, Voltage) are reported - /// as zero so Octopus receives a complete, parseable payload. /// private object[] BuildSampledValues(IEnumerable measurands, MeterSample sample, string context) { + var ext = GetCurrentExternalValues(); var result = new List(); foreach (var measurand in measurands) { - var entry = BuildSingleMeasurand(measurand, sample, context); + var entry = BuildSingleMeasurand(measurand, sample, ext, context); if (entry is not null) { result.Add(entry); @@ -1853,27 +1900,43 @@ private object[] BuildSampledValues(IEnumerable measurands, MeterSample return result.ToArray(); } - private Dictionary? BuildSingleMeasurand(string measurand, MeterSample sample, string context) + private Dictionary? BuildSingleMeasurand(string measurand, MeterSample sample, ExternalMeterValues? ext, string context) { // Map each measurand to its value and unit. - // For measurands this charger does not support (V2G export, Voltage, Frequency) - // we return 0 so Octopus knows what the charger is reporting. + // External (Home Assistant) values take priority where available. return measurand.ToUpperInvariant() switch { - "ENERGY.ACTIVE.IMPORT.REGISTER" => MeasurandEntry(measurand, sample.EnergyWh.ToString("0", CultureInfo.InvariantCulture), "Wh", context), - "POWER.ACTIVE.IMPORT" => MeasurandEntry(measurand, sample.PowerKw.ToString("0.0", CultureInfo.InvariantCulture), "kW", context), - "CURRENT.IMPORT" => MeasurandEntry(measurand, sample.CurrentAmps.ToString("0.0", CultureInfo.InvariantCulture), "A", context), - "CURRENT.OFFERED" => MeasurandEntry(measurand, sample.CurrentAmps.ToString("0.0", CultureInfo.InvariantCulture), "A", context), - "POWER.OFFERED" => MeasurandEntry(measurand, sample.PowerKw.ToString("0.0", CultureInfo.InvariantCulture), "kW", context), - "FREQUENCY" => MeasurandEntry(measurand, GridFrequencyHz.ToString("0.0", CultureInfo.InvariantCulture), "Hz", context), - "SOC" when _supportSoC && sample.StateOfCharge >= 0 => MeasurandEntry(measurand, sample.StateOfCharge.ToString("0.0", CultureInfo.InvariantCulture), "Percent", context), + "ENERGY.ACTIVE.IMPORT.REGISTER" => MeasurandEntry(measurand, + (ext?.EnergyWhImport ?? sample.EnergyWh).ToString("0", CultureInfo.InvariantCulture), "Wh", context), + + "POWER.ACTIVE.IMPORT" => MeasurandEntry(measurand, + (ext?.PowerKwImport ?? sample.PowerKw).ToString("0.0", CultureInfo.InvariantCulture), "kW", context), + + "CURRENT.IMPORT" => MeasurandEntry(measurand, + (ext?.CurrentAmpsOffered ?? sample.CurrentAmps).ToString("0.0", CultureInfo.InvariantCulture), "A", context), + + "CURRENT.OFFERED" => MeasurandEntry(measurand, + (ext?.CurrentAmpsOffered ?? sample.CurrentAmps).ToString("0.0", CultureInfo.InvariantCulture), "A", context), + + "POWER.OFFERED" => MeasurandEntry(measurand, + (ext?.PowerKwOffered ?? ext?.PowerKwImport ?? sample.PowerKw).ToString("0.0", CultureInfo.InvariantCulture), "kW", context), + + "FREQUENCY" => MeasurandEntry(measurand, + (ext?.FrequencyHz ?? GridFrequencyHz).ToString("0.0", CultureInfo.InvariantCulture), "Hz", context), + + "SOC" when _supportSoC || (ext?.StateOfChargePercent.HasValue == true) => MeasurandEntry(measurand, + (ext?.StateOfChargePercent ?? (_supportSoC && sample.StateOfCharge >= 0 ? sample.StateOfCharge : 0)).ToString("0.0", CultureInfo.InvariantCulture), "Percent", context), + "SOC" => MeasurandEntry(measurand, "0.0", "Percent", context), - // V2G / export measurands — this charger does not support V2G + + // V2G / export measurands — not supported, always 0 "ENERGY.ACTIVE.EXPORT.REGISTER" => MeasurandEntry(measurand, "0", "Wh", context), "POWER.ACTIVE.EXPORT" => MeasurandEntry(measurand, "0.0", "kW", context), "CURRENT.EXPORT" => MeasurandEntry(measurand, "0.0", "A", context), + "VOLTAGE" => MeasurandEntry(measurand, NominalVoltage.ToString("0.0", CultureInfo.InvariantCulture), "V", context), - // Unknown measurands are silently skipped rather than sending garbage data + + // Unknown measurands are silently skipped _ => null, }; } @@ -1923,6 +1986,9 @@ private async Task SendBootMeterValuesAsync(CancellationToken cancellationToken) var bootSample = new MeterSample(energyValue, sample.PowerKw, sample.CurrentAmps, _supportSoC ? FixedStateOfCharge : -1, DateTimeOffset.UtcNow); + // Overlay with real values from Home Assistant if available + bootSample = ApplyExternalValues(bootSample); + // At boot we report the full set of sampled measurands so Octopus can initialise all channels var measurands = GetSampledMeasurands(); var sampledValues = BuildSampledValues(measurands, bootSample, "Sample.Clock"); From 1e5bf6dea6d5952092492d02cc590a65c2455922 Mon Sep 17 00:00:00 2001 From: andy-81 Date: Sat, 28 Mar 2026 17:05:20 +0000 Subject: [PATCH 03/16] Adding additional services to receive data from HA --- .../Services/ExternalMeterValues.cs | 26 +++++++++++++ .../Services/SimulatorHostedService.cs | 4 ++ OCPPChargerSim/Services/SimulatorState.cs | 38 +++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 OCPPChargerSim/Services/ExternalMeterValues.cs diff --git a/OCPPChargerSim/Services/ExternalMeterValues.cs b/OCPPChargerSim/Services/ExternalMeterValues.cs new file mode 100644 index 0000000..7f33d20 --- /dev/null +++ b/OCPPChargerSim/Services/ExternalMeterValues.cs @@ -0,0 +1,26 @@ +namespace OcppWeb.Services; + +/// +/// Holds the latest meter values pushed in from an external source (e.g. Home Assistant). +/// All fields are optional — null means "use simulated value". +/// +public sealed class ExternalMeterValues +{ + /// Energy.Active.Import.Register in Wh + public double? EnergyWhImport { get; set; } + + /// Power.Active.Import in kW + public double? PowerKwImport { get; set; } + + /// Frequency in Hz + public double? FrequencyHz { get; set; } + + /// Power.Offered in kW + public double? PowerKwOffered { get; set; } + + /// Current.Offered in A + public double? CurrentAmpsOffered { get; set; } + + /// SoC in % (optional — only if your car exposes it) + public double? StateOfChargePercent { get; set; } +} diff --git a/OCPPChargerSim/Services/SimulatorHostedService.cs b/OCPPChargerSim/Services/SimulatorHostedService.cs index 8ad0a9f..762cc01 100644 --- a/OCPPChargerSim/Services/SimulatorHostedService.cs +++ b/OCPPChargerSim/Services/SimulatorHostedService.cs @@ -130,6 +130,10 @@ private async Task RunWithConfigurationAsync(SimulatorConfigurationSnapshot snap supportSoC: options.SupportSoC, enableHeartbeat: options.SupportHeartbeat); + // Wire up Home Assistant / external meter values so the client always + // reads the latest pushed values when it builds a MeterValues payload. + client.SetExternalMeterValuesProvider(() => _state.GetExternalMeterValues()); + _coordinator.Attach(client, logger); _state.SetVehicleState(client.VehicleState); _state.SetConfigurationSnapshot(client.ConfigurationSnapshot); diff --git a/OCPPChargerSim/Services/SimulatorState.cs b/OCPPChargerSim/Services/SimulatorState.cs index f8494b9..9540b5b 100644 --- a/OCPPChargerSim/Services/SimulatorState.cs +++ b/OCPPChargerSim/Services/SimulatorState.cs @@ -22,6 +22,7 @@ public sealed class SimulatorState private string? _selectedChargerId; private string _chargePointSerial = "0"; private string _chargeBoxSerial = "0"; + private ExternalMeterValues? _externalMeterValues; public void AddLog(string message) { @@ -221,4 +222,41 @@ public IReadOnlyDictionary GetBootConfiguration() return new Dictionary(_bootConfiguration); } } + + /// + /// Stores the latest meter values received from an external source (e.g. Home Assistant). + /// Pass null to clear and revert to simulated values. + /// + public void SetExternalMeterValues(ExternalMeterValues? values) + { + lock (_sync) + { + _externalMeterValues = values; + } + } + + /// + /// Returns a snapshot of the latest external meter values, or null if none have been received. + /// + public ExternalMeterValues? GetExternalMeterValues() + { + lock (_sync) + { + if (_externalMeterValues is null) + { + return null; + } + + // Return a copy so callers can't mutate shared state + return new ExternalMeterValues + { + EnergyWhImport = _externalMeterValues.EnergyWhImport, + PowerKwImport = _externalMeterValues.PowerKwImport, + FrequencyHz = _externalMeterValues.FrequencyHz, + PowerKwOffered = _externalMeterValues.PowerKwOffered, + CurrentAmpsOffered = _externalMeterValues.CurrentAmpsOffered, + StateOfChargePercent = _externalMeterValues.StateOfChargePercent, + }; + } + } } From 9c4fa79cfc7c24e230df4cbe58e2967bce5aa260 Mon Sep 17 00:00:00 2001 From: andy-81 Date: Sat, 28 Mar 2026 17:06:07 +0000 Subject: [PATCH 04/16] Updating the Program for HA integration --- OCPPChargerSim/Program.cs | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/OCPPChargerSim/Program.cs b/OCPPChargerSim/Program.cs index ee277b4..eef8268 100644 --- a/OCPPChargerSim/Program.cs +++ b/OCPPChargerSim/Program.cs @@ -135,12 +135,52 @@ } }); +// --------------------------------------------------------------------------- +// External meter values endpoint +// Called by Home Assistant (or any external source) to push real meter data. +// The simulator will use these values in the next MeterValues message to Octopus. +// POST /api/meters +// { +// "energyWhImport": 256937, // Energy.Active.Import.Register in Wh +// "powerKwImport": 3.45, // Power.Active.Import in kW +// "frequencyHz": 50.01, // Frequency in Hz +// "powerKwOffered": 3.45, // Power.Offered in kW +// "currentAmpsOffered": 15.0, // Current.Offered in A +// "stateOfChargePercent": 42.0 // SoC in % (optional) +// } +// All fields are optional — omit any you don't have and the simulator will +// fall back to its own calculated value for that measurand. +// --------------------------------------------------------------------------- +app.MapPost("/api/meters", (ExternalMeterValues values, SimulatorState state) => +{ + state.SetExternalMeterValues(values); + return Results.Accepted(); +}); + +app.MapGet("/api/meters", (SimulatorState state) => +{ + var values = state.GetExternalMeterValues(); + if (values is null) + { + return Results.Ok(new { source = "simulated", values = (object?)null }); + } + + return Results.Ok(new { source = "external", values }); +}); + +app.MapDelete("/api/meters", (SimulatorState state) => +{ + state.SetExternalMeterValues(null); + return Results.Accepted(); +}); + app.MapGet("/api/state", (SimulatorState state, SimulatorConfigurationProvider configProvider, ChargerCatalog catalog) => { var sample = state.LatestSample; var (url, identity, authKey) = state.GetConnectionDetails(); var (requiresConfiguration, configFileMissing) = state.ConfigurationStatus; var (chargePointSerial, chargeBoxSerial) = state.GetSerialNumbers(); + var externalMeters = state.GetExternalMeterValues(); return Results.Ok(new { vehicleState = state.VehicleState, @@ -168,6 +208,7 @@ }), selectedCharger = state.SelectedChargerId, serialNumbers = new { chargePointSerial, chargeBoxSerial }, + externalMeterSource = externalMeters is not null ? "external" : "simulated", }); }); From c152082452424c34589ff230299666cf0bc1aba7 Mon Sep 17 00:00:00 2001 From: andy-81 Date: Sat, 28 Mar 2026 20:58:34 +0000 Subject: [PATCH 05/16] Add files via upload --- OCPPChargerSim/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/OCPPChargerSim/Program.cs b/OCPPChargerSim/Program.cs index eef8268..6eb0356 100644 --- a/OCPPChargerSim/Program.cs +++ b/OCPPChargerSim/Program.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using OcppWeb.Hubs; +using OcppSimulator; using OcppWeb.Services; var builder = WebApplication.CreateBuilder(args); From bb2b7cc67d412db81b5b937fa54c55ab13cbd580 Mon Sep 17 00:00:00 2001 From: andy-81 Date: Sat, 28 Mar 2026 20:58:58 +0000 Subject: [PATCH 06/16] Add files via upload --- .../Simulator/ExternalMeterValues.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 OCPPChargerSim/Simulator/ExternalMeterValues.cs diff --git a/OCPPChargerSim/Simulator/ExternalMeterValues.cs b/OCPPChargerSim/Simulator/ExternalMeterValues.cs new file mode 100644 index 0000000..417381f --- /dev/null +++ b/OCPPChargerSim/Simulator/ExternalMeterValues.cs @@ -0,0 +1,26 @@ +namespace OcppSimulator; + +/// +/// Holds the latest meter values pushed in from an external source (e.g. Home Assistant). +/// All fields are optional — null means "use simulated value". +/// +public sealed class ExternalMeterValues +{ + /// Energy.Active.Import.Register in Wh + public double? EnergyWhImport { get; set; } + + /// Power.Active.Import in kW + public double? PowerKwImport { get; set; } + + /// Frequency in Hz + public double? FrequencyHz { get; set; } + + /// Power.Offered in kW + public double? PowerKwOffered { get; set; } + + /// Current.Offered in A + public double? CurrentAmpsOffered { get; set; } + + /// SoC in % (optional — only if your car exposes it) + public double? StateOfChargePercent { get; set; } +} From 938e64d715620e9bc59a913ea615022810279ba3 Mon Sep 17 00:00:00 2001 From: andy-81 Date: Sat, 28 Mar 2026 21:06:01 +0000 Subject: [PATCH 07/16] Delete OCPPChargerSim/Services/ExternalMeterValues.cs --- .../Services/ExternalMeterValues.cs | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 OCPPChargerSim/Services/ExternalMeterValues.cs diff --git a/OCPPChargerSim/Services/ExternalMeterValues.cs b/OCPPChargerSim/Services/ExternalMeterValues.cs deleted file mode 100644 index 7f33d20..0000000 --- a/OCPPChargerSim/Services/ExternalMeterValues.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace OcppWeb.Services; - -/// -/// Holds the latest meter values pushed in from an external source (e.g. Home Assistant). -/// All fields are optional — null means "use simulated value". -/// -public sealed class ExternalMeterValues -{ - /// Energy.Active.Import.Register in Wh - public double? EnergyWhImport { get; set; } - - /// Power.Active.Import in kW - public double? PowerKwImport { get; set; } - - /// Frequency in Hz - public double? FrequencyHz { get; set; } - - /// Power.Offered in kW - public double? PowerKwOffered { get; set; } - - /// Current.Offered in A - public double? CurrentAmpsOffered { get; set; } - - /// SoC in % (optional — only if your car exposes it) - public double? StateOfChargePercent { get; set; } -} From a0bfeed51ed8faa3577a21135c62e71cc924c0d8 Mon Sep 17 00:00:00 2001 From: andy-81 Date: Fri, 3 Apr 2026 16:37:42 +0100 Subject: [PATCH 08/16] Add files via upload From c957bb42499c1df22cc7eaf69a6d3b6c8605592f Mon Sep 17 00:00:00 2001 From: andy-81 Date: Fri, 3 Apr 2026 16:38:29 +0100 Subject: [PATCH 09/16] Add files via upload --- OCPPChargerSim/Program.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/OCPPChargerSim/Program.cs b/OCPPChargerSim/Program.cs index 6eb0356..ca5e49e 100644 --- a/OCPPChargerSim/Program.cs +++ b/OCPPChargerSim/Program.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using OcppWeb.Hubs; -using OcppSimulator; using OcppWeb.Services; var builder = WebApplication.CreateBuilder(args); @@ -152,7 +151,7 @@ // All fields are optional — omit any you don't have and the simulator will // fall back to its own calculated value for that measurand. // --------------------------------------------------------------------------- -app.MapPost("/api/meters", (ExternalMeterValues values, SimulatorState state) => +app.MapPost("/api/meters", (OcppSimulator.ExternalMeterValues values, SimulatorState state) => { state.SetExternalMeterValues(values); return Results.Accepted(); From c35f33ba3da3ceeed94ea272c2820803fc8828d1 Mon Sep 17 00:00:00 2001 From: andy-81 Date: Fri, 3 Apr 2026 16:39:01 +0100 Subject: [PATCH 10/16] Add files via upload --- OCPPChargerSim/Services/SimulatorHostedService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OCPPChargerSim/Services/SimulatorHostedService.cs b/OCPPChargerSim/Services/SimulatorHostedService.cs index 762cc01..156d051 100644 --- a/OCPPChargerSim/Services/SimulatorHostedService.cs +++ b/OCPPChargerSim/Services/SimulatorHostedService.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using OcppSimulator; + using OcppWeb.Hubs; namespace OcppWeb.Services; From fd56977ee81ee9386c2c27cf2155dbc44bcc4ba9 Mon Sep 17 00:00:00 2001 From: andy-81 Date: Fri, 3 Apr 2026 16:39:32 +0100 Subject: [PATCH 11/16] Add files via upload --- OCPPChargerSim/Services/SimulatorState.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/OCPPChargerSim/Services/SimulatorState.cs b/OCPPChargerSim/Services/SimulatorState.cs index 9540b5b..087c32b 100644 --- a/OCPPChargerSim/Services/SimulatorState.cs +++ b/OCPPChargerSim/Services/SimulatorState.cs @@ -22,7 +22,7 @@ public sealed class SimulatorState private string? _selectedChargerId; private string _chargePointSerial = "0"; private string _chargeBoxSerial = "0"; - private ExternalMeterValues? _externalMeterValues; + private OcppSimulator.ExternalMeterValues? _externalMeterValues; public void AddLog(string message) { @@ -227,7 +227,7 @@ public IReadOnlyDictionary GetBootConfiguration() /// Stores the latest meter values received from an external source (e.g. Home Assistant). /// Pass null to clear and revert to simulated values. /// - public void SetExternalMeterValues(ExternalMeterValues? values) + public void SetExternalMeterValues(OcppSimulator.ExternalMeterValues? values) { lock (_sync) { @@ -238,7 +238,7 @@ public void SetExternalMeterValues(ExternalMeterValues? values) /// /// Returns a snapshot of the latest external meter values, or null if none have been received. /// - public ExternalMeterValues? GetExternalMeterValues() + public OcppSimulator.ExternalMeterValues? GetExternalMeterValues() { lock (_sync) { @@ -248,7 +248,7 @@ public void SetExternalMeterValues(ExternalMeterValues? values) } // Return a copy so callers can't mutate shared state - return new ExternalMeterValues + return new OcppSimulator.ExternalMeterValues { EnergyWhImport = _externalMeterValues.EnergyWhImport, PowerKwImport = _externalMeterValues.PowerKwImport, From 727a587d095613b8c1d811ba84e61d82cdea37b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 15:51:54 +0000 Subject: [PATCH 12/16] Fix CS0246: add missing using OcppSimulator to SimulatorHostedService MeterSample lives in the OcppSimulator namespace but the using directive was absent, causing the build to fail with CS0246. https://claude.ai/code/session_01WMUoER43pGRCgJxTkar5wc --- OCPPChargerSim/Services/SimulatorHostedService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/OCPPChargerSim/Services/SimulatorHostedService.cs b/OCPPChargerSim/Services/SimulatorHostedService.cs index 156d051..1d6ea52 100644 --- a/OCPPChargerSim/Services/SimulatorHostedService.cs +++ b/OCPPChargerSim/Services/SimulatorHostedService.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using OcppSimulator; using OcppWeb.Hubs; namespace OcppWeb.Services; From 6674499e164c9bfd5eeb5dd8a2937a2bede9eada Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 18:36:10 +0000 Subject: [PATCH 13/16] Fix OCPP FormatViolation: use "Hertz" instead of "Hz" for Frequency unit OCPP 1.6 schema only accepts "Hertz" in the UnitOfMeasure enum; "Hz" is not a valid value and the server was rejecting every MeterValues message that included the Frequency measurand. https://claude.ai/code/session_01WMUoER43pGRCgJxTkar5wc --- OCPPChargerSim/Simulator/ChargerClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OCPPChargerSim/Simulator/ChargerClient.cs b/OCPPChargerSim/Simulator/ChargerClient.cs index 5658fa9..fce7d0f 100644 --- a/OCPPChargerSim/Simulator/ChargerClient.cs +++ b/OCPPChargerSim/Simulator/ChargerClient.cs @@ -1922,7 +1922,7 @@ private object[] BuildSampledValues(IEnumerable measurands, MeterSample (ext?.PowerKwOffered ?? ext?.PowerKwImport ?? sample.PowerKw).ToString("0.0", CultureInfo.InvariantCulture), "kW", context), "FREQUENCY" => MeasurandEntry(measurand, - (ext?.FrequencyHz ?? GridFrequencyHz).ToString("0.0", CultureInfo.InvariantCulture), "Hz", context), + (ext?.FrequencyHz ?? GridFrequencyHz).ToString("0.0", CultureInfo.InvariantCulture), "Hertz", context), "SOC" when _supportSoC || (ext?.StateOfChargePercent.HasValue == true) => MeasurandEntry(measurand, (ext?.StateOfChargePercent ?? (_supportSoC && sample.StateOfCharge >= 0 ? sample.StateOfCharge : 0)).ToString("0.0", CultureInfo.InvariantCulture), "Percent", context), From df6cc4124deb8abf2e98d15d22956536cab8cfb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 06:25:02 +0000 Subject: [PATCH 14/16] Add transactionData to StopTransaction for Transaction.End meter snapshot OCPP 1.6 StopTransaction.req supports a transactionData field containing a final meter snapshot with context "Transaction.End". Wallbox chargers include this so the CSMS (Octopus) can confirm the total energy delivered and verify the charging target was reached. Without it, Octopus cannot reconcile the session and reports "Charge unsuccessful". The measurands are taken from StopTxnSampledData config, falling back to MeterValuesSampledData, then Energy.Active.Import.Register. https://claude.ai/code/session_01WMUoER43pGRCgJxTkar5wc --- OCPPChargerSim/Simulator/ChargerClient.cs | 34 ++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/OCPPChargerSim/Simulator/ChargerClient.cs b/OCPPChargerSim/Simulator/ChargerClient.cs index fce7d0f..9e22661 100644 --- a/OCPPChargerSim/Simulator/ChargerClient.cs +++ b/OCPPChargerSim/Simulator/ChargerClient.cs @@ -1313,11 +1313,13 @@ private async Task SendStopTransactionAsync(string reason, CancellationTok _meterValue = (int)Math.Round(_meterAccumulatorWh, MidpointRounding.AwayFromZero); + var stopTimestamp = DateTimeOffset.UtcNow.ToString("O"); + var payload = new Dictionary { ["transactionId"] = _activeTransactionId.Value, ["meterStop"] = _meterValue, - ["timestamp"] = DateTimeOffset.UtcNow.ToString("O"), + ["timestamp"] = stopTimestamp, ["reason"] = reason, }; @@ -1326,6 +1328,21 @@ private async Task SendStopTransactionAsync(string reason, CancellationTok payload["idTag"] = _activeIdTag!; } + var stopTxnMeasurands = GetStopTxnMeasurands(); + if (stopTxnMeasurands.Count > 0) + { + var sample = ApplyExternalValues(LatestSample); + var sampledValues = BuildSampledValues(stopTxnMeasurands, sample, "Transaction.End"); + payload["transactionData"] = new object[] + { + new Dictionary + { + ["timestamp"] = stopTimestamp, + ["sampledValue"] = sampledValues, + }, + }; + } + await SendCallAsync(uniqueId, "StopTransaction", payload, cancellationToken).ConfigureAwait(false); try @@ -1814,6 +1831,21 @@ private List GetSampledMeasurands() return ParseMeasurandList(configured, new[] { "Energy.Active.Import.Register", "Power.Active.Import" }); } + private List GetStopTxnMeasurands() + { + string? configured; + lock (_configuration) + { + _configuration.TryGetValue("StopTxnSampledData", out configured); + if (string.IsNullOrWhiteSpace(configured)) + { + _configuration.TryGetValue("MeterValuesSampledData", out configured); + } + } + + return ParseMeasurandList(configured, new[] { "Energy.Active.Import.Register" }); + } + /// /// Returns the list of measurands from MeterValuesAlignedData config. /// From be84aed0763b4678a98f6597310ea6ceb9cc3bd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 06:40:44 +0000 Subject: [PATCH 15/16] Fix Current.Offered and Power.Offered to reflect OCPP-commanded limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current.Offered should report what the charger is offering the vehicle (the limit set by Octopus via ChangeConfiguration chargingALimitConn1), not the actual charging current. Power.Offered is derived from that same offered current × NominalVoltage rather than falling back to actual import power. Also fixed Current.Import to use actual measured current rather than the HA-provided CurrentAmpsOffered field (which is the offered limit, not the draw). Priority chain: - Current.Offered: HA override → chargingALimitConn1 → MaxCurrentAmps (32A) - Power.Offered: HA override → offeredAmps × 230V / 1000 - Current.Import: HA power / 230V → simulated current https://claude.ai/code/session_01WMUoER43pGRCgJxTkar5wc --- OCPPChargerSim/Simulator/ChargerClient.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/OCPPChargerSim/Simulator/ChargerClient.cs b/OCPPChargerSim/Simulator/ChargerClient.cs index 9e22661..ab6bff5 100644 --- a/OCPPChargerSim/Simulator/ChargerClient.cs +++ b/OCPPChargerSim/Simulator/ChargerClient.cs @@ -1945,13 +1945,15 @@ private object[] BuildSampledValues(IEnumerable measurands, MeterSample (ext?.PowerKwImport ?? sample.PowerKw).ToString("0.0", CultureInfo.InvariantCulture), "kW", context), "CURRENT.IMPORT" => MeasurandEntry(measurand, - (ext?.CurrentAmpsOffered ?? sample.CurrentAmps).ToString("0.0", CultureInfo.InvariantCulture), "A", context), + (ext?.PowerKwImport.HasValue == true + ? ext!.PowerKwImport.Value * 1000.0 / NominalVoltage + : sample.CurrentAmps).ToString("0.0", CultureInfo.InvariantCulture), "A", context), "CURRENT.OFFERED" => MeasurandEntry(measurand, - (ext?.CurrentAmpsOffered ?? sample.CurrentAmps).ToString("0.0", CultureInfo.InvariantCulture), "A", context), + (ext?.CurrentAmpsOffered ?? GetConfiguredCurrentLimit() ?? MaxCurrentAmps).ToString("0.0", CultureInfo.InvariantCulture), "A", context), "POWER.OFFERED" => MeasurandEntry(measurand, - (ext?.PowerKwOffered ?? ext?.PowerKwImport ?? sample.PowerKw).ToString("0.0", CultureInfo.InvariantCulture), "kW", context), + (ext?.PowerKwOffered ?? (ext?.CurrentAmpsOffered ?? GetConfiguredCurrentLimit() ?? MaxCurrentAmps) * NominalVoltage / 1000.0).ToString("0.0", CultureInfo.InvariantCulture), "kW", context), "FREQUENCY" => MeasurandEntry(measurand, (ext?.FrequencyHz ?? GridFrequencyHz).ToString("0.0", CultureInfo.InvariantCulture), "Hertz", context), From a34e8c3cbaf9a575cc5e4cadaa883d99d882e8a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 06:48:49 +0000 Subject: [PATCH 16/16] Add CurrentAmpsImport to ExternalMeterValues for direct HA current reporting Current.Import now uses ext.CurrentAmpsImport from HA directly, falling back to the simulated current. Previously it derived amps from PowerKwImport which was a workaround. HA can now POST currentAmpsImport to /api/meters. https://claude.ai/code/session_01WMUoER43pGRCgJxTkar5wc --- OCPPChargerSim/Program.cs | 5 +++-- OCPPChargerSim/Services/SimulatorState.cs | 1 + OCPPChargerSim/Simulator/ChargerClient.cs | 4 +--- OCPPChargerSim/Simulator/ExternalMeterValues.cs | 3 +++ 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/OCPPChargerSim/Program.cs b/OCPPChargerSim/Program.cs index ca5e49e..6eed125 100644 --- a/OCPPChargerSim/Program.cs +++ b/OCPPChargerSim/Program.cs @@ -144,8 +144,9 @@ // "energyWhImport": 256937, // Energy.Active.Import.Register in Wh // "powerKwImport": 3.45, // Power.Active.Import in kW // "frequencyHz": 50.01, // Frequency in Hz -// "powerKwOffered": 3.45, // Power.Offered in kW -// "currentAmpsOffered": 15.0, // Current.Offered in A +// "powerKwOffered": 3.45, // Power.Offered in kW (optional — calculated from currentAmpsOffered if omitted) +// "currentAmpsImport": 15.0, // Current.Import — actual draw in A +// "currentAmpsOffered": 15.0, // Current.Offered in A (optional — uses chargingALimitConn1 if omitted) // "stateOfChargePercent": 42.0 // SoC in % (optional) // } // All fields are optional — omit any you don't have and the simulator will diff --git a/OCPPChargerSim/Services/SimulatorState.cs b/OCPPChargerSim/Services/SimulatorState.cs index 087c32b..d1d64f6 100644 --- a/OCPPChargerSim/Services/SimulatorState.cs +++ b/OCPPChargerSim/Services/SimulatorState.cs @@ -254,6 +254,7 @@ public void SetExternalMeterValues(OcppSimulator.ExternalMeterValues? values) PowerKwImport = _externalMeterValues.PowerKwImport, FrequencyHz = _externalMeterValues.FrequencyHz, PowerKwOffered = _externalMeterValues.PowerKwOffered, + CurrentAmpsImport = _externalMeterValues.CurrentAmpsImport, CurrentAmpsOffered = _externalMeterValues.CurrentAmpsOffered, StateOfChargePercent = _externalMeterValues.StateOfChargePercent, }; diff --git a/OCPPChargerSim/Simulator/ChargerClient.cs b/OCPPChargerSim/Simulator/ChargerClient.cs index ab6bff5..19589b0 100644 --- a/OCPPChargerSim/Simulator/ChargerClient.cs +++ b/OCPPChargerSim/Simulator/ChargerClient.cs @@ -1945,9 +1945,7 @@ private object[] BuildSampledValues(IEnumerable measurands, MeterSample (ext?.PowerKwImport ?? sample.PowerKw).ToString("0.0", CultureInfo.InvariantCulture), "kW", context), "CURRENT.IMPORT" => MeasurandEntry(measurand, - (ext?.PowerKwImport.HasValue == true - ? ext!.PowerKwImport.Value * 1000.0 / NominalVoltage - : sample.CurrentAmps).ToString("0.0", CultureInfo.InvariantCulture), "A", context), + (ext?.CurrentAmpsImport ?? sample.CurrentAmps).ToString("0.0", CultureInfo.InvariantCulture), "A", context), "CURRENT.OFFERED" => MeasurandEntry(measurand, (ext?.CurrentAmpsOffered ?? GetConfiguredCurrentLimit() ?? MaxCurrentAmps).ToString("0.0", CultureInfo.InvariantCulture), "A", context), diff --git a/OCPPChargerSim/Simulator/ExternalMeterValues.cs b/OCPPChargerSim/Simulator/ExternalMeterValues.cs index 417381f..cb177dc 100644 --- a/OCPPChargerSim/Simulator/ExternalMeterValues.cs +++ b/OCPPChargerSim/Simulator/ExternalMeterValues.cs @@ -18,6 +18,9 @@ public sealed class ExternalMeterValues /// Power.Offered in kW public double? PowerKwOffered { get; set; } + /// Current.Import (actual draw) in A + public double? CurrentAmpsImport { get; set; } + /// Current.Offered in A public double? CurrentAmpsOffered { get; set; }