diff --git a/OCPPChargerSim/Program.cs b/OCPPChargerSim/Program.cs index ee277b4..6eed125 100644 --- a/OCPPChargerSim/Program.cs +++ b/OCPPChargerSim/Program.cs @@ -135,12 +135,53 @@ } }); +// --------------------------------------------------------------------------- +// 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 (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 +// fall back to its own calculated value for that measurand. +// --------------------------------------------------------------------------- +app.MapPost("/api/meters", (OcppSimulator.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 +209,7 @@ }), selectedCharger = state.SelectedChargerId, serialNumbers = new { chargePointSerial, chargeBoxSerial }, + externalMeterSource = externalMeters is not null ? "external" : "simulated", }); }); diff --git a/OCPPChargerSim/Services/SimulatorHostedService.cs b/OCPPChargerSim/Services/SimulatorHostedService.cs index 8ad0a9f..1d6ea52 100644 --- a/OCPPChargerSim/Services/SimulatorHostedService.cs +++ b/OCPPChargerSim/Services/SimulatorHostedService.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; + using OcppSimulator; using OcppWeb.Hubs; @@ -130,6 +131,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..d1d64f6 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 OcppSimulator.ExternalMeterValues? _externalMeterValues; public void AddLog(string message) { @@ -221,4 +222,42 @@ 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(OcppSimulator.ExternalMeterValues? values) + { + lock (_sync) + { + _externalMeterValues = values; + } + } + + /// + /// Returns a snapshot of the latest external meter values, or null if none have been received. + /// + public OcppSimulator.ExternalMeterValues? GetExternalMeterValues() + { + lock (_sync) + { + if (_externalMeterValues is null) + { + return null; + } + + // Return a copy so callers can't mutate shared state + return new OcppSimulator.ExternalMeterValues + { + EnergyWhImport = _externalMeterValues.EnergyWhImport, + 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 cb507b4..19589b0 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(); @@ -73,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; @@ -110,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)) @@ -127,6 +139,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 +300,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 +351,7 @@ public async Task RunAsync(CancellationToken cancellationToken) await EnsureRemoteStartConfigurationAsync(cancellationToken).ConfigureAwait(false); StartHeartbeatLoop(cancellationToken); + StartClockAlignedLoop(cancellationToken); await receiveTask.ConfigureAwait(false); } @@ -354,6 +371,7 @@ public async Task RunAsync(CancellationToken cancellationToken) { StopMeterValueLoop(); StopHeartbeatLoop(); + StopClockAlignedLoop(); var socket = _webSocket; _webSocket = null; @@ -604,48 +622,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 +788,145 @@ 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; + // Overlay with real values from Home Assistant if available + sample = ApplyExternalValues(sample); + 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 +1036,15 @@ 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); + + // 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"); var payload = new Dictionary { @@ -896,49 +1054,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 +1075,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 +1152,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 +1297,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) @@ -1188,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, }; @@ -1201,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 @@ -1315,6 +1457,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 +1693,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 +1780,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 +1812,178 @@ 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" }); + } + + 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. + /// + 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); + } + + /// + /// 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. + /// + 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, ext, context); + if (entry is not null) + { + result.Add(entry); + } + } + + return result.ToArray(); + } + + private Dictionary? BuildSingleMeasurand(string measurand, MeterSample sample, ExternalMeterValues? ext, string context) + { + // Map each measurand to its value and unit. + // External (Home Assistant) values take priority where available. + return measurand.ToUpperInvariant() switch + { + "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?.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), + + "POWER.OFFERED" => MeasurandEntry(measurand, + (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), + + "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 — 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 + _ => 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 +2015,15 @@ 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", - }, - }; + var bootSample = new MeterSample(energyValue, sample.PowerKw, sample.CurrentAmps, + _supportSoC ? FixedStateOfCharge : -1, DateTimeOffset.UtcNow); - sampledValues.Add(new Dictionary - { - ["value"] = powerValue.ToString("0.0", CultureInfo.InvariantCulture), - ["measurand"] = "Power.Active.Import", - ["unit"] = "kW", - ["context"] = "Sample.Clock", - }); + // Overlay with real values from Home Assistant if available + bootSample = ApplyExternalValues(bootSample); - sampledValues.Add(new Dictionary - { - ["value"] = currentValue.ToString("0.0", CultureInfo.InvariantCulture), - ["measurand"] = "Current.Import", - ["unit"] = "A", - ["context"] = "Sample.Clock", - }); - - 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 +2033,7 @@ private async Task SendBootMeterValuesAsync(CancellationToken cancellationToken) new Dictionary { ["timestamp"] = DateTimeOffset.UtcNow.ToString("O"), - ["sampledValue"] = sampledValues.ToArray(), + ["sampledValue"] = sampledValues, }, }, }; @@ -1738,7 +2058,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() diff --git a/OCPPChargerSim/Simulator/ExternalMeterValues.cs b/OCPPChargerSim/Simulator/ExternalMeterValues.cs new file mode 100644 index 0000000..cb177dc --- /dev/null +++ b/OCPPChargerSim/Simulator/ExternalMeterValues.cs @@ -0,0 +1,29 @@ +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.Import (actual draw) in A + public double? CurrentAmpsImport { 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; } +}