diff --git a/csharp/App/KacoCommunication/DataLogging/LogFileConcatenator.cs b/csharp/App/KacoCommunication/DataLogging/LogFileConcatenator.cs new file mode 100644 index 000000000..4dfc03f7c --- /dev/null +++ b/csharp/App/KacoCommunication/DataLogging/LogFileConcatenator.cs @@ -0,0 +1,33 @@ +using System.Text; + +namespace InnovEnergy.App.KacoCommunication.DataLogging; + +public class LogFileConcatenator +{ + private readonly String _LogDirectory; + + public LogFileConcatenator(String logDirectory = "JsonLogDirectory/") + { + _LogDirectory = logDirectory; + } + + public String ConcatenateFiles(int numberOfFiles) + { + var logFiles = Directory + .GetFiles(_LogDirectory, "log_*.json") + .OrderByDescending(file => file) + .Take(numberOfFiles) + .OrderBy(file => file) + .ToList(); + + var concatenatedContent = new StringBuilder(); + + foreach (var fileContent in logFiles.Select(File.ReadAllText)) + { + concatenatedContent.AppendLine(fileContent); + //concatenatedContent.AppendLine(); // Append an empty line to separate the files // maybe we don't need this + } + + return concatenatedContent.ToString(); + } +} \ No newline at end of file diff --git a/csharp/App/KacoCommunication/DataLogging/Logfile.cs b/csharp/App/KacoCommunication/DataLogging/Logfile.cs new file mode 100644 index 000000000..2bd43df35 --- /dev/null +++ b/csharp/App/KacoCommunication/DataLogging/Logfile.cs @@ -0,0 +1,48 @@ +using InnovEnergy.Lib.Utils; +using Microsoft.Extensions.Logging; +namespace InnovEnergy.App.KacoCommunication.DataLogging; + +public class CustomLogger : ILogger +{ + private readonly String _LogFilePath; + //private readonly Int64 _maxFileSizeBytes; + private readonly Int32 _MaxLogFileCount; + private Int64 _CurrentFileSizeBytes; + + public CustomLogger(String logFilePath, Int32 maxLogFileCount) + { + _LogFilePath = logFilePath; + _MaxLogFileCount = maxLogFileCount; + _CurrentFileSizeBytes = File.Exists(logFilePath) ? new FileInfo(logFilePath).Length : 0; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => throw new NotImplementedException(); + + public Boolean IsEnabled(LogLevel logLevel) => true; // Enable logging for all levels + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var logMessage = formatter(state, exception!); + + // Check the log file count and delete the oldest file if necessary + var logFileDir = Path.GetDirectoryName(_LogFilePath)!; + var logFileExt = Path.GetExtension(_LogFilePath); + var logFileBaseName = Path.GetFileNameWithoutExtension(_LogFilePath); + + var logFiles = Directory + .GetFiles(logFileDir, $"{logFileBaseName}_*{logFileExt}") + .OrderBy(file => file) + .ToList(); + + if (logFiles.Count >= _MaxLogFileCount) + { + File.Delete(logFiles.First()); + } + + var roundedUnixTimestamp = DateTime.Now.ToUnixTime() % 2 == 0 ? DateTime.Now.ToUnixTime() : DateTime.Now.ToUnixTime() + 1; + var timestamp = "Timestamp;" + roundedUnixTimestamp + Environment.NewLine; + + var logFileBackupPath = Path.Combine(logFileDir, $"{logFileBaseName}_{DateTime.Now.ToUnixTime()}{logFileExt}"); + File.AppendAllText(logFileBackupPath, timestamp + logMessage + Environment.NewLine); + } +} \ No newline at end of file diff --git a/csharp/App/KacoCommunication/DataLogging/Logger.cs b/csharp/App/KacoCommunication/DataLogging/Logger.cs new file mode 100644 index 000000000..1894e016b --- /dev/null +++ b/csharp/App/KacoCommunication/DataLogging/Logger.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Logging; + +namespace InnovEnergy.App.KacoCommunication.DataLogging; + +public static class Logger +{ + // Specify the maximum log file size in bytes (e.g., 1 MB) + + //private const Int32 MaxFileSizeBytes = 2024 * 30; // TODO: move to settings + private const Int32 MaxLogFileCount = 5000; // TODO: move to settings + private const String LogFilePath = "JsonLogDirectory/log.json"; // TODO: move to settings + + // ReSharper disable once InconsistentNaming + private static readonly ILogger _logger = new CustomLogger(LogFilePath, MaxLogFileCount); + + public static T LogInfo(this T t) where T : notnull + { + _logger.LogInformation(t.ToString()); // TODO: check warning + return t; + } + + public static T LogDebug(this T t) where T : notnull + { + // _logger.LogDebug(t.ToString()); // TODO: check warning + return t; + } + + public static T LogError(this T t) where T : notnull + { + // _logger.LogError(t.ToString()); // TODO: check warning + return t; + } + + public static T LogWarning(this T t) where T : notnull + { + // _logger.LogWarning(t.ToString()); // TODO: check warning + return t; + } +} \ No newline at end of file diff --git a/csharp/App/KacoCommunication/DataTypes/AlarmOrWarning.cs b/csharp/App/KacoCommunication/DataTypes/AlarmOrWarning.cs new file mode 100644 index 000000000..d7c1fdb47 --- /dev/null +++ b/csharp/App/KacoCommunication/DataTypes/AlarmOrWarning.cs @@ -0,0 +1,9 @@ +namespace InnovEnergy.App.KacoCommunication.DataTypes; + +public class AlarmOrWarning +{ + public String? Date { get; set; } + public String? Time { get; set; } + public String? Description { get; set; } + public String? CreatedBy { get; set; } +} \ No newline at end of file diff --git a/csharp/App/KacoCommunication/DataTypes/Configuration.cs b/csharp/App/KacoCommunication/DataTypes/Configuration.cs new file mode 100644 index 000000000..01618e249 --- /dev/null +++ b/csharp/App/KacoCommunication/DataTypes/Configuration.cs @@ -0,0 +1,19 @@ + +namespace InnovEnergy.App.KacoCommunication.DataTypes; + +public class Configuration +{ + public Double MinimumSoC { get; set; } + public Double MaximumDischargingCurrent { get; set; } + public Double MaximumChargingCurrent { get; set; } + //public WorkingMode OperatingPriority { get; set; } + public Int16 BatteriesCount { get; set; } + public Int16 ClusterNumber { get; set; } + public Int16 PvNumber { get; set; } + public DateTime StartTimeChargeandDischargeDayandTime { get; set; } + public DateTime StopTimeChargeandDischargeDayandTime { get; set; } + public Single TimeChargeandDischargePower { get; set; } + public Boolean ControlPermission { get; set; } + +} + diff --git a/csharp/App/KacoCommunication/DataTypes/SodiStoreAlarmState.cs b/csharp/App/KacoCommunication/DataTypes/SodiStoreAlarmState.cs new file mode 100644 index 000000000..0e39dea0b --- /dev/null +++ b/csharp/App/KacoCommunication/DataTypes/SodiStoreAlarmState.cs @@ -0,0 +1,8 @@ +namespace InnovEnergy.App.KacoCommunication.DataTypes; + +public enum SodistoreAlarmState +{ + Green, + Orange, + Red +} diff --git a/csharp/App/KacoCommunication/DataTypes/StatusMessage.cs b/csharp/App/KacoCommunication/DataTypes/StatusMessage.cs new file mode 100644 index 000000000..ea1bac319 --- /dev/null +++ b/csharp/App/KacoCommunication/DataTypes/StatusMessage.cs @@ -0,0 +1,17 @@ +namespace InnovEnergy.App.KacoCommunication.DataTypes; + +public class StatusMessage +{ + public required Int32 InstallationId { get; set; } + public required Int32 Product { get; set; } + public required SodistoreAlarmState Status { get; set; } + public required MessageType Type { get; set; } + public List? Warnings { get; set; } + public List? Alarms { get; set; } +} + +public enum MessageType +{ + AlarmOrWarning, + Heartbit +} \ No newline at end of file diff --git a/csharp/App/KacoCommunication/Devices/DeviceState.cs b/csharp/App/KacoCommunication/Devices/DeviceState.cs new file mode 100644 index 000000000..5fe89e073 --- /dev/null +++ b/csharp/App/KacoCommunication/Devices/DeviceState.cs @@ -0,0 +1,8 @@ +namespace InnovEnergy.App.KacoCommunication.Devices; + +public enum DeviceState +{ + Disabled, + Measured, + Computed +} diff --git a/csharp/App/KacoCommunication/Devices/SalimaxDevice.cs b/csharp/App/KacoCommunication/Devices/SalimaxDevice.cs new file mode 100644 index 000000000..242d3ea05 --- /dev/null +++ b/csharp/App/KacoCommunication/Devices/SalimaxDevice.cs @@ -0,0 +1,9 @@ +using InnovEnergy.Lib.Utils.Net; + +namespace InnovEnergy.App.KacoCommunication.Devices; + +public class SalimaxDevice : Ip4Address +{ + public required DeviceState DeviceState { get; init; } +} + diff --git a/csharp/App/KacoCommunication/ESS/StatusRecord.cs b/csharp/App/KacoCommunication/ESS/StatusRecord.cs new file mode 100644 index 000000000..787d95d26 --- /dev/null +++ b/csharp/App/KacoCommunication/ESS/StatusRecord.cs @@ -0,0 +1,23 @@ +using InnovEnergy.App.KacoCommunication.SystemConfig; +using InnovEnergy.Lib.Devices.BatteryDeligreen; +using InnovEnergy.Lib.Devices.Kaco92L3; +using InnovEnergy.Lib.Devices.PLVario2Meter; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertDc; + +namespace InnovEnergy.App.KacoCommunication.ESS; + +public class StatusRecord +{ + public required KacoRecord? InverterRecord { get; set; } + public required PlVarioMeterRecord? GridMeterRecord { get; set; } + public required DcDcDevicesRecord? DcDc { get; init; } + + public required BatteryDeligreenRecords? BatteryKabinet1 { get; set; } + public required BatteryDeligreenRecords? BatteryKabinet2 { get; set; } + public required BatteryDeligreenRecords? BatteryKabinet3 { get; set; } + + + + public required Config Config { get; set; } +} + diff --git a/csharp/App/KacoCommunication/Flow.cs b/csharp/App/KacoCommunication/Flow.cs new file mode 100644 index 000000000..45c1476b6 --- /dev/null +++ b/csharp/App/KacoCommunication/Flow.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; +using InnovEnergy.Lib.Units; +using InnovEnergy.Lib.Utils; + +namespace InnovEnergy.App.KacoCommunication; + +public static class Flow +{ + private static readonly String RightArrowChar = ">"; + private static readonly String LeftArrowChar = "<"; + private static readonly String DownArrowChar = "V"; + private static readonly String UpArrowChar = "^"; + private static readonly String UnknownArrowChar = "?"; + + public static TextBlock Horizontal(Unit? amount) => Horizontal(amount, 10); + + public static TextBlock Horizontal(Unit? amount, Int32 width) + { + var label = amount?.ToDisplayString() ?? ""; + + var arrowChar = amount switch + { + { Value: < 0 } => LeftArrowChar, + { Value: >= 0 } => RightArrowChar, + _ => UnknownArrowChar, + }; + + //var arrowChar = amount.Value < 0 ? LeftArrowChar : RightArrowChar; + var arrow = Enumerable.Repeat(arrowChar, width).Join(); + + // note : appending "fake label" below to make it vertically symmetric + return TextBlock.AlignCenterHorizontal(label, arrow, ""); + } + + public static TextBlock Vertical(Unit? amount) => Vertical(amount, 4); + + [SuppressMessage("ReSharper", "PossibleMultipleEnumeration")] + [SuppressMessage("ReSharper", "CoVariantArrayConversion")] + public static TextBlock Vertical(Unit? amount, Int32 height) + { + var label = amount?.ToDisplayString() ?? UnknownArrowChar; + var arrowChar = amount switch + { + { Value: < 0 } => UpArrowChar, + { Value: >= 0 } => DownArrowChar, + _ => UnknownArrowChar, + }; + + // var arrowChar = amount is null ? UnknownArrowChar + // : amount.Value < 0 ? UpArrowChar + // : DownArrowChar; + + return TextBlock.AlignCenterHorizontal(arrowChar, arrowChar, label, arrowChar, arrowChar); + } +} diff --git a/csharp/App/KacoCommunication/KacoCommunication.csproj b/csharp/App/KacoCommunication/KacoCommunication.csproj new file mode 100644 index 000000000..cc9b562ed --- /dev/null +++ b/csharp/App/KacoCommunication/KacoCommunication.csproj @@ -0,0 +1,24 @@ + + + + + InnovEnergy.App.KacoCommunication + + + + + + + + + + + + + + + + + + + diff --git a/csharp/App/KacoCommunication/MiddlewareClasses/MiddlewareAgent.cs b/csharp/App/KacoCommunication/MiddlewareClasses/MiddlewareAgent.cs new file mode 100644 index 000000000..d14793ae4 --- /dev/null +++ b/csharp/App/KacoCommunication/MiddlewareClasses/MiddlewareAgent.cs @@ -0,0 +1,91 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using InnovEnergy.App.KacoCommunication.DataTypes; + +namespace InnovEnergy.App.KacoCommunication.MiddlewareClasses; + +public static class MiddlewareAgent +{ + private static UdpClient _udpListener = null!; + private static IPAddress? _controllerIpAddress; + private static EndPoint? _endPoint; + + public static void InitializeCommunicationToMiddleware() + { + _controllerIpAddress = FindVpnIp(); + if (Equals(IPAddress.None, _controllerIpAddress)) + { + Console.WriteLine("There is no VPN interface, exiting..."); + } + + const Int32 udpPort = 9000; + _endPoint = new IPEndPoint(_controllerIpAddress, udpPort); + + _udpListener = new UdpClient(); + _udpListener.Client.Blocking = false; + _udpListener.Client.Bind(_endPoint); + } + + private static IPAddress FindVpnIp() + { + const String interfaceName = "innovenergy"; + + var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces(); + + foreach (var networkInterface in networkInterfaces) + { + if (networkInterface.Name == interfaceName) + { + var ipProps = networkInterface.GetIPProperties(); + var uniCastIPs = ipProps.UnicastAddresses; + var controllerIpAddress = uniCastIPs[0].Address; + + Console.WriteLine("VPN IP is: "+ uniCastIPs[0].Address); + return controllerIpAddress; + } + } + + return IPAddress.None; + } + + public static Configuration? SetConfigurationFile() + { + if (_udpListener.Available > 0) + { + IPEndPoint? serverEndpoint = null; + + var replyMessage = "ACK"; + var replyData = Encoding.UTF8.GetBytes(replyMessage); + + var udpMessage = _udpListener.Receive(ref serverEndpoint); + var message = Encoding.UTF8.GetString(udpMessage); + + var config = JsonSerializer.Deserialize(message); + + if (config != null) + { + Console.WriteLine($"Received a configuration message: " + + "MinimumSoC is " + config.MinimumSoC + "Number of batteries is " + config.BatteriesCount + + "Maximum Charging current is "+ config.MaximumChargingCurrent + "/n" + "Maximum Discharging current is " + config.MaximumDischargingCurrent + + "StartTimeChargeandDischargeDayandTime is" + config.StartTimeChargeandDischargeDayandTime + "StopTimeChargeandDischargeDayandTime is" + config.StopTimeChargeandDischargeDayandTime + + "TimeChargeandDischargePowert is " + config.TimeChargeandDischargePower + " Control permission is" + config.ControlPermission); + + // Send the reply to the sender's endpoint + _udpListener.Send(replyData, replyData.Length, serverEndpoint); + Console.WriteLine($"Replied to {serverEndpoint}: {replyMessage}"); + return config; + } + } + + if (_endPoint != null && !_endPoint.Equals(_udpListener.Client.LocalEndPoint as IPEndPoint)) + { + Console.WriteLine("UDP address has changed, rebinding..."); + InitializeCommunicationToMiddleware(); + } + return null; + } + +} \ No newline at end of file diff --git a/csharp/App/KacoCommunication/MiddlewareClasses/RabbitMqManager.cs b/csharp/App/KacoCommunication/MiddlewareClasses/RabbitMqManager.cs new file mode 100644 index 000000000..ded8b6e1d --- /dev/null +++ b/csharp/App/KacoCommunication/MiddlewareClasses/RabbitMqManager.cs @@ -0,0 +1,64 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using InnovEnergy.App.KacoCommunication.DataTypes; +using RabbitMQ.Client; + +namespace InnovEnergy.App.KacoCommunication.MiddlewareClasses; + + +[SuppressMessage("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic access otherwise can break functionality when trimming application code")] +public static class RabbitMqManager +{ + public static ConnectionFactory? Factory ; + public static IConnection ? Connection; + public static IModel? Channel; + + public static Boolean SubscribeToQueue(StatusMessage currentSalimaxState, String? s3Bucket,String VpnServerIp) + { + try + { + //_factory = new ConnectionFactory { HostName = VpnServerIp }; + + Factory = new ConnectionFactory + { + HostName = VpnServerIp, + Port = 5672, + VirtualHost = "/", + UserName = "producer", + Password = "b187ceaddb54d5485063ddc1d41af66f", + + }; + + Connection = Factory.CreateConnection(); + Channel = Connection.CreateModel(); + Channel.QueueDeclare(queue: "statusQueue", durable: true, exclusive: false, autoDelete: false, arguments: null); + + Console.WriteLine("The controller sends its status to the middleware for the first time"); + if (s3Bucket != null) InformMiddleware(currentSalimaxState); + + + } + catch (Exception ex) + { + Console.WriteLine("An error occurred while connecting to the RabbitMQ queue: " + ex.Message); + return false; + } + return true; + } + + public static void InformMiddleware(StatusMessage status) + { + var message = JsonSerializer.Serialize(status); + var body = Encoding.UTF8.GetBytes(message); + + Channel.BasicPublish(exchange: string.Empty, + routingKey: "statusQueue", + basicProperties: null, + body: body); + + Console.WriteLine($"Producer sent message: {message}"); + } + + +} \ No newline at end of file diff --git a/csharp/App/KacoCommunication/Program.cs b/csharp/App/KacoCommunication/Program.cs new file mode 100644 index 000000000..d17e6d3bc --- /dev/null +++ b/csharp/App/KacoCommunication/Program.cs @@ -0,0 +1,637 @@ +// See https://aka.ms/new-console-template for more information + +using System.IO.Compression; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Text; +using System.Text.Json; +using Flurl.Http; +using InnovEnergy.App.KacoCommunication.DataLogging; +using InnovEnergy.App.KacoCommunication.DataTypes; +using InnovEnergy.App.KacoCommunication.Devices; +using InnovEnergy.App.KacoCommunication.ESS; +using InnovEnergy.App.KacoCommunication.MiddlewareClasses; +using InnovEnergy.App.KacoCommunication.SystemConfig; +using InnovEnergy.Lib.Devices.BatteryDeligreen; +using InnovEnergy.Lib.Devices.Kaco92L3; +using InnovEnergy.Lib.Devices.Kaco92L3.DataType; +using InnovEnergy.Lib.Devices.PLVario2Meter; +using InnovEnergy.Lib.Devices.Trumpf.TruConvertDc; +using InnovEnergy.Lib.Protocols.Modbus.Channels; +using InnovEnergy.Lib.Units; +using InnovEnergy.Lib.Utils; +using Newtonsoft.Json; +using Formatting = Newtonsoft.Json.Formatting; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace InnovEnergy.App.KacoCommunication; + +internal static class Program +{ + private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(5); + private const UInt16 NbrOfFileToConcatenate = 15; // add this to config file + private static UInt16 _fileCounter = 0; + + + + private static SodistoreAlarmState _prevSodiohomeAlarmState = SodistoreAlarmState.Green; + private static SodistoreAlarmState _sodiAlarmState = SodistoreAlarmState.Green; + + + private static readonly IReadOnlyList BatteryNodes; + private static readonly Channel KacoChannel; + private static readonly Channel GridMeterChannel; + private static readonly Channel DcDcChannel; + + private const String Port1Cabinet = "/dev/ttyUSB0"; // move to a config file + private const String Port2Cabinet = "/dev/ttyUSB1"; // move to a config file + private const String Port3Cabinet = "/dev/ttyUSB2"; // move to a config file + + private static readonly String SwVersionNumber = " V1.00." + DateTime.Today; + private const String VpnServerIp = "10.2.0.11"; + public static Boolean _subscribedToQueue = false; + public static Boolean _subscribeToQueueForTheFirstTime = false; + + private static Int32 _failsCounter = 0; // move to a config file + // private static SodistoreAlarmState _prevSodiohomeAlarmState = SodistoreAlarmState.Green; + // private static SodistoreAlarmState _sodiohomeAlarmState = SodistoreAlarmState.Green; + + static Program() + { + var config = Config.Load(); + var d = config.Devices; + + Channel CreateChannel(SalimaxDevice device) => device.DeviceState == DeviceState.Disabled + ? new NullChannel() + : new TcpChannel(device); + + BatteryNodes = config + .Devices + .BatteryNodes + .Select(n => n.ConvertTo()) + .ToArray(config.Devices.BatteryNodes.Length); + + KacoChannel = CreateChannel(d.KacoIp); + GridMeterChannel = CreateChannel(d.GridMeterIp); + DcDcChannel = CreateChannel(d.DcDcIp); + + } + + + public static async Task Main(String[] args) + { + + while (true) + { + try + { + await Run(); + } + catch (Exception e) + { + // e.LogError(); + } + } + // ReSharper disable once FunctionNeverReturns + } + + private static async Task Run() + { + Watchdog.NotifyReady(); + + Console.WriteLine("Starting Kaco Communication"); + + var kacoDevice = new KacoDevice(KacoChannel); + var gridMeterDevice = new PlVarioMeterDevice(GridMeterChannel); + var dcDcDevices = new TruConvertDcDcDevices(DcDcChannel); + + var firstCabinetBatteriesDevice = BatteryNodes.Select(n => new BatteryDeligreenDevice(Port1Cabinet, n)).ToList(); + var secondCabinetBatteriesDevice = BatteryNodes.Select(n => new BatteryDeligreenDevice(Port2Cabinet, n)).ToList(); + var thirdCabinetBatteriesDevice = BatteryNodes.Select(n => new BatteryDeligreenDevice(Port3Cabinet, n)).ToList(); + + var batteryDevices1 = new BatteryDeligreenDevices(firstCabinetBatteriesDevice); + var batteryDevices2 = new BatteryDeligreenDevices(secondCabinetBatteriesDevice); + var batteryDevices3 = new BatteryDeligreenDevices(thirdCabinetBatteriesDevice); + + StatusRecord? ReadStatus() + { + PlVarioMeterRecord? gridRecord = null; + var config = Config.Load(); + var kacoRecord = kacoDevice.Read(); + var gridrawRecord = gridMeterDevice.Read(); + var dcDcRecord = dcDcDevices.Read(); + if (gridrawRecord != null) + { + gridRecord = new PlVarioMeterRecord(gridrawRecord); + } + + var batteryKabinet1 = batteryDevices1.Read(); + var batteryKabinet2 = batteryDevices2.Read(); + var batteryKabinet3 = batteryDevices3.Read(); + + return new StatusRecord + { + InverterRecord = kacoRecord, + GridMeterRecord = gridRecord, + DcDc = dcDcRecord, + BatteryKabinet1 = batteryKabinet1, + BatteryKabinet2 = batteryKabinet2, + BatteryKabinet3 = batteryKabinet3, + Config = config // load from disk every iteration, so config can be changed while running + }; + } + + while (true) + { + await Observable + .Interval(UpdateInterval) + .Select(_ => RunIteration()) + .SelectMany(status => + DataLogging(status, DateTime.Now.Round(UpdateInterval)) + .ContinueWith(_ => status)) // back to StatusRecord + .SelectMany(SaveModbusTcpFile) + .SelectError() + .ToTask(); + } + + + StatusRecord? RunIteration() + { + try + { + Watchdog.NotifyAlive(); + + var startTime = DateTime.Now; + Console.WriteLine( + "***************************** Reading Kaco Data *********************************************"); + Console.WriteLine(startTime.ToString("HH:mm:ss.fff")); + // the order matter of the next three lines + var statusrecord = ReadStatus(); + statusrecord?.CreateSimpleTopologyTextBlock().WriteLine(); + + + // statusrecord?.DcDc?.Dc.Battery.Power .WriteLine(" Power"); + // statusrecord?.DcDc?.Dc.Battery.Voltage .WriteLine(" Voltage"); + // statusrecord?.DcDc?.Dc.Battery.Current .WriteLine(" Current"); + // statusrecord?.DcDc?.Dc.Link.Voltage .WriteLine(" Dc link Voltage"); + + + statusrecord?.GridMeterRecord?.Frequency .WriteLine(" Frequency"); + statusrecord?.GridMeterRecord?.VoltageU1 .WriteLine(" VoltageU1"); + statusrecord?.GridMeterRecord?.VoltageU2 .WriteLine(" VoltageU2"); + statusrecord?.GridMeterRecord?.VoltageU3 .WriteLine(" VoltageU3"); + + statusrecord?.GridMeterRecord?.CurrentI1 .WriteLine(" CurrentI1"); + statusrecord?.GridMeterRecord?.CurrentI2 .WriteLine(" CurrentI2"); + statusrecord?.GridMeterRecord?.CurrentI3 .WriteLine(" CurrentI3"); + + statusrecord?.GridMeterRecord?.ActivePowerL1 .WriteLine(" ActivePowerL1"); + statusrecord?.GridMeterRecord?.ActivePowerL2 .WriteLine(" ActivePowerL2"); + statusrecord?.GridMeterRecord?.ActivePowerL3 .WriteLine(" ActivePowerL3"); + statusrecord?.GridMeterRecord?.ActivePowerTotal .WriteLine(" ActivePowerTotal"); + + statusrecord?.InverterRecord?.CurrentState.WriteLine(" CurrentState"); + statusrecord?.InverterRecord?.RequestedState.WriteLine(" RequestedState"); + statusrecord?.InverterRecord?.PcuError.WriteLine(" PcuError"); + statusrecord?.InverterRecord?.PcuState.WriteLine(" PcuState"); + + statusrecord?.InverterRecord?.BattCharId.WriteLine(" _battCharId"); + statusrecord?.InverterRecord?.BattCharLength.WriteLine(" _battCharLength"); + + statusrecord?.InverterRecord?.MinDischargeVoltage.WriteLine(" MinDischargeVoltage"); + statusrecord?.InverterRecord?.MaxDischargeCurrent.WriteLine(" MaxDischargeCurrent"); + statusrecord?.InverterRecord?.DischargeCutoffCurrent.WriteLine(" DischargeCutoffCurrent"); + + statusrecord?.InverterRecord?.MaxChargeVoltage.WriteLine(" MaxChargeVoltage"); + statusrecord?.InverterRecord?.MaxChargeCurrent.WriteLine(" MaxChargeCurrent"); + statusrecord?.InverterRecord?.ChargeCutoffCurrent.WriteLine(" ChargeCutoffCurrent"); + + statusrecord?.InverterRecord?.ActivePowerSetPercent.WriteLine(" ActivePowerSetPercent"); + statusrecord?.InverterRecord?.ReactivePowerSetPercent.WriteLine(" ReactivePowerSetPercent"); + statusrecord?.InverterRecord?.WatchdogSeconds.WriteLine(" WatchdogSeconds"); + InitializeKacoStartup(statusrecord); + + Console.WriteLine( " ************************************ We are writing ************************************"); + + statusrecord?.Config.Save(); // save the config file + if (statusrecord?.InverterRecord != null) kacoDevice.Write(statusrecord.InverterRecord); + Console.WriteLine(DateTime.Now.ToString("HH:mm:ss.fff")); + + return statusrecord; + } + catch (Exception e) + { + // Handle exception and print the error + Console.WriteLine(e); + return null; + } + } + } + + private static async Task SavingLocalCsvFile(Int64 timestamp, String csv) + { + const String directoryPath = "/home/inesco/salimax/csvFile"; + + // Ensure directory exists + if (!Directory.Exists(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + // Get all .csv files ordered by creation time (oldest first) + var csvFiles = new DirectoryInfo(directoryPath) + .GetFiles("*.csv") + .OrderBy(f => f.CreationTimeUtc) + .ToList(); + + // If more than 5000 files, delete the oldest + if (csvFiles.Count >= 5000) + { + var oldestFile = csvFiles.First(); + try + { + oldestFile.Delete(); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to delete file: {oldestFile.FullName}, Error: {ex.Message}"); + } + } + + // Prepare the filtered CSV content + var filteredCsv = csv + .SplitLines() + .Where(l => !l.Contains("Secret")) + .JoinLines(); + + // Save the new CSV file + var filePath = Path.Combine(directoryPath, timestamp + ".csv"); + await File.WriteAllTextAsync(filePath, filteredCsv); + } + + private static async Task DataLogging(StatusRecord status, DateTime timeStamp) + { + var csv = status.ToCsv(); + + // for debug, only to be deleted. + //foreach (var item in csv.SplitLines()) + //{ + // Console.WriteLine(item + ""); + //} + + await SavingLocalCsvFile(timeStamp.ToUnixTime(), csv); + + var jsonData = new Dictionary(); + + ConvertToJson(csv, jsonData).LogInfo(); + + var s3Config = status.Config.S3; + + if (s3Config is null) + return false; + + //Concatenating 15 files in one file + return await ConcatinatingAndCompressingFiles(timeStamp.ToUnixTime(), s3Config); + } + + private static String ConvertToJson(String csv, Dictionary jsonData) + { + foreach (var line in csv.Split('\n')) + { + if (string.IsNullOrWhiteSpace(line)) continue; + + var parts = line.Split(';'); + var keyPath = parts[0]; + var value = parts[1]; + var unit = parts.Length > 2 ? parts[2].Trim() : ""; + InsertIntoJson(jsonData, keyPath.Split('/'), value); + } + + var jsonOutput = JsonConvert.SerializeObject(jsonData, Formatting.None); + return jsonOutput; + } + + private static async Task ConcatinatingAndCompressingFiles(Int64 timeStamp, S3Config s3Config) + { + if (_fileCounter >= NbrOfFileToConcatenate) + { + _fileCounter = 0; + + var logFileConcatenator = new LogFileConcatenator(); + var jsontoSend = logFileConcatenator.ConcatenateFiles(NbrOfFileToConcatenate); + + var fileNameWithoutExtension = timeStamp.ToString(); // used for both S3 and local + var s3Path = fileNameWithoutExtension + ".json"; + + var request = s3Config.CreatePutRequest(s3Path); + + var compressedBytes = CompresseBytes(jsontoSend); + var base64String = Convert.ToBase64String(compressedBytes); + var stringContent = new StringContent(base64String, Encoding.UTF8, "application/base64"); + + var uploadSucceeded = false; + + try + { + var response = await request.PutAsync(stringContent); + + if (response.StatusCode != 200) + { + Console.WriteLine("ERROR: PUT"); + var error = await response.GetStringAsync(); + Console.WriteLine(error); + + await SaveToLocalCompressedFallback(compressedBytes, fileNameWithoutExtension); + Heartbit(); + return false; + } + + uploadSucceeded = true; + Console.WriteLine("✅ File uploaded to S3 successfully."); + + Console.WriteLine( + "---------------------------------------- Resending FailedUploadedFiles----------------------------------------"); + Heartbit(); + + await ResendLocalFailedFilesAsync(s3Config); // retry any pending failed files + } + catch (Exception ex) + { + Console.WriteLine("Upload exception: " + ex.Message); + + if (!uploadSucceeded) + { + await SaveToLocalCompressedFallback(compressedBytes, fileNameWithoutExtension); + } + + Heartbit(); + return false; + } + } + _fileCounter++; + return true; + } + + private static void Heartbit() + { + var s3Bucket = Config.Load().S3?.Bucket; + var tryParse = int.TryParse(s3Bucket?.Split("-")[0], out var installationId); + if (tryParse) + { + var returnedStatus = new StatusMessage + { + InstallationId = installationId, + Product = 3, + Status = _sodiAlarmState, + Type = MessageType.Heartbit, + }; + if (s3Bucket != null) + RabbitMqManager.InformMiddleware(returnedStatus); + } + } + + private static async Task SaveToLocalCompressedFallback(Byte[] compressedData, String fileNameWithoutExtension) + { + try + { + var fallbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FailedUploads"); + Directory.CreateDirectory(fallbackDir); + + var fileName = fileNameWithoutExtension + ".json"; // Save as .json, but still compressed + var fullPath = Path.Combine(fallbackDir, fileName); + + await File.WriteAllBytesAsync(fullPath, compressedData); // Compressed data + Console.WriteLine($"Saved compressed failed upload to: {fullPath}"); + } + catch (Exception ex) + { + Console.WriteLine("Failed to save compressed file locally: " + ex.Message); + } + } + + private static Byte[] CompresseBytes(String jsonToSend) + { + //Compress JSON data to a byte array + using var memoryStream = new MemoryStream(); + //Create a zip directory and put the compressed file inside + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) + { + var entry = archive.CreateEntry("data.json", CompressionLevel.SmallestSize); // Add JSON data to the ZIP archive + using (var entryStream = entry.Open()) + using (var writer = new StreamWriter(entryStream)) + { + writer.Write(jsonToSend); + } + } + + var compressedBytes = memoryStream.ToArray(); + + return compressedBytes; + } + + private static async Task ResendLocalFailedFilesAsync(S3Config s3Config) + { + var fallbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FailedUploads"); + + if (!Directory.Exists(fallbackDir)) + return; + + var files = Directory.GetFiles(fallbackDir, "*.json"); + files.Length.WriteLine(" Number of failed files, to upload"); + + foreach (var filePath in files) + { + var fileName = Path.GetFileName(filePath); // e.g., "1720023600.json" + + try + { + byte[] compressedBytes = await File.ReadAllBytesAsync(filePath); + var base64String = Convert.ToBase64String(compressedBytes); + var stringContent = new StringContent(base64String, Encoding.UTF8, "application/base64"); + + var request = s3Config.CreatePutRequest(fileName); + var response = await request.PutAsync(stringContent); + + if (response.StatusCode == 200) + { + File.Delete(filePath); + Console.WriteLine($"✅ Successfully resent and deleted: {fileName}"); + } + else + { + Console.WriteLine($"❌ Failed to resend {fileName}, status: {response.StatusCode}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"⚠️ Exception while resending {fileName}: {ex.Message}"); + } + } + } + + private static async Task SaveModbusTcpFile(StatusRecord status) + { + var modbusData = new Dictionary(); + + // SYSTEM DATA + var result1 = ConvertToModbusRegisters((status.Config.MinSoc * 10), "UInt16", 30001); // this to be updated to modbusTCP version + var result2 = ConvertToModbusRegisters(status.InverterRecord!.PcuError, "UInt32", 30002); + + // Merge all results into one dictionary + + var allResults = new[] + { + result1,result2 + }; + + foreach (var result in allResults) + { + foreach (var entry in result) + { + modbusData[entry.Key] = entry.Value; + } + } + // Write to JSON + var json = JsonSerializer.Serialize(modbusData, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync("/home/inesco/SodiStoreHome/ModbusTCP/modbus_tcp_data.json", json); + + //Console.WriteLine("JSON file written successfully."); + //Console.WriteLine(json); + var stopTime = DateTime.Now; + Console.WriteLine(stopTime.ToString("HH:mm:ss.fff" )+ " Finish the loop"); + return true; + } + + private static Dictionary ConvertToModbusRegisters(Object value, String outputType, + Int32 startingAddress) + { + var registers = new Dictionary(); + + switch (outputType) + { + case "UInt16": + registers[startingAddress.ToString()] = Convert.ToUInt16(value); + break; + + case "Int16": + var int16Val = Convert.ToInt16(value); + registers[startingAddress.ToString()] = (UInt16)int16Val; // reinterpret signed as ushort + break; + + case "UInt32": + var uint32Val = Convert.ToUInt32(value); + registers[startingAddress.ToString()] = (UInt16)(uint32Val & 0xFFFF); // Low word + registers[(startingAddress + 1).ToString()] = (UInt16)(uint32Val >> 16); // High word + break; + + case "Int32": + var int32Val = Convert.ToInt32(value); + var raw = unchecked((UInt32)int32Val); // reinterprets signed int as unsigned + registers[startingAddress.ToString()] = (UInt16)(raw & 0xFFFF); + registers[(startingAddress + 1).ToString()] = (UInt16)(raw >> 16); + break; + + default: + throw new ArgumentException("Unsupported output type: " + outputType); + } + return registers; + } + + + private static void InitializeKacoStartup(StatusRecord? statusRecord) + { + // + // 1. Apply DC – This part is physical and cannot be done in software. + // We assume DC power is already present. + // + + // + // 2. Send valid battery limits (Model 64202) + // All values temporarily set to "1" as requested. + // You will replace them later with real values. + // + if (statusRecord?.InverterRecord != null) + { + statusRecord.InverterRecord.MinDischargeVoltage = 700f; // 64202.DisMinV + statusRecord.InverterRecord.MaxDischargeCurrent = 140f; // 64202.DisMaxA + statusRecord.InverterRecord.DischargeCutoffCurrent = 10f; // 64202.DisCutoffA + + statusRecord.InverterRecord.MaxChargeVoltage = 800f; // 64202.ChaMaxV + statusRecord.InverterRecord.MaxChargeCurrent = 140f; // 64202.ChaMaxA + statusRecord.InverterRecord.ChargeCutoffCurrent = 10f; // 64202.ChaCutoffA + + statusRecord.InverterRecord.WatchdogSeconds = 30; // this is additional from my seid + + // + // 3. Enable limits (EnLimit) + // + statusRecord.InverterRecord.BatteryLimitsEnable = EnableDisableEnum.Enabled; + + // + // After writing all values in software, send them to the inverter + // + // + // 4. Read model 64201 to observe CurrentState transition + // + // Expected sequence: + // - Before valid limits: CurrentState == 7 (ERROR) + // - After valid limits: CurrentState == 8 (STANDBY) + // - Then after grid/DC conditions: CurrentState == 1 (OFF) or 11 (GRID_CONNECTED) + // + + var state = statusRecord.InverterRecord.CurrentState; + + Console.WriteLine($"KACO 64201.CurrentState = {state}"); + + switch (state) + { + case CurrentState.Standby: + Console.WriteLine("Device is in STANDBY (8) — battery limits accepted."); + break; + + case CurrentState.Off: + Console.WriteLine("Device is OFF (1) — OK for non-battery operation."); + break; + + case CurrentState.GridConnected: + Console.WriteLine("Device is GRID CONNECTED (11)."); + break; + + default: + Console.WriteLine("Device in unexpected state: " + state); + break; + } + //Thread.Sleep(2000); + } + } + private static void InsertIntoJson(Dictionary jsonDict, String[] keys, String value) + { + var currentDict = jsonDict; + for (Int16 i = 1; i < keys.Length; i++) // Start at 1 to skip empty root + { + var key = keys[i]; + if (!currentDict.ContainsKey(key)) + { + currentDict[key] = new Dictionary(); + } + + if (i == keys.Length - 1) // Last key, store the value + { + + if (!value.Contains(",") && double.TryParse(value, out Double doubleValue)) // Try to parse value as a number + { + currentDict[key] = Math.Round(doubleValue, 2); // Round to 2 decimal places + + } + else + { + currentDict[key] = value; // Store as string if not a number + } + } + else + { + currentDict = (Dictionary)currentDict[key]; + } + } + } +} \ No newline at end of file diff --git a/csharp/App/KacoCommunication/SystemConfig/Config.cs b/csharp/App/KacoCommunication/SystemConfig/Config.cs new file mode 100644 index 000000000..84c6c6b89 --- /dev/null +++ b/csharp/App/KacoCommunication/SystemConfig/Config.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using InnovEnergy.App.KacoCommunication.Devices; +using InnovEnergy.App.KacoCommunication.ESS; +using InnovEnergy.Lib.Utils; +using static System.Text.Json.JsonSerializer; + +namespace InnovEnergy.App.KacoCommunication.SystemConfig; + +public class Config +{ + private static String DefaultConfigFilePath => Path.Combine(Environment.CurrentDirectory, "config.json"); + private static DateTime DefaultDatetime => new(2024, 03, 11, 09, 00, 00); + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public required Double MinSoc { get; set; } + + /* public required Double MaximumDischargingCurrent { get; set; } + public required Double MaximumChargingCurrent { get; set; } + public required Int16 BatteriesCount { get; set; } + public required Double ModbusProtcolNumber { get; set; }*/ + public required DeviceConfig Devices { get; set; } + + public required S3Config? S3 { get; set; } + + + private static String? LastSavedData { get; set; } + + public static Config Default => new() + { + MinSoc = 20, + /* MaximumChargingCurrent = 180, + MaximumDischargingCurrent = 180, + BatteriesCount = 0, + ModbusProtcolNumber = 1.2,*/ + Devices = new () + { + KacoIp = new() { Host = "10.0.3.1", Port = 502, DeviceState = DeviceState.Measured}, + DcDcIp = new() { Host = "10.0.2.1", Port = 502, DeviceState = DeviceState.Measured}, + GridMeterIp = new() { Host = "192.168.1.5", Port = 502, DeviceState = DeviceState.Measured}, + + BatteryNodes = new []{0,1, 2, 3, 4, 5 } + }, + + S3 = new() + { + Bucket = "1-3e5b3069-214a-43ee-8d85-57d72000c19d", + Region = "sos-ch-dk-2", + Provider = "exo.io", + Key = "EXObb5a49acb1061781761895e7", + Secret = "sKhln0w8ii3ezZ1SJFF33yeDo8NWR1V4w2H0D4-350I", + ContentType = "text/plain; charset=utf-8" + } + }; + + public void Save(String? path = null) + { + var configFilePath = path ?? DefaultConfigFilePath; + + try + { + var jsonString = Serialize(this, JsonOptions); + + if (LastSavedData == jsonString) + return; + + LastSavedData = jsonString; + + File.WriteAllText(configFilePath, jsonString); + } + catch (Exception e) + { + $"Failed to write config file {configFilePath}\n{e}".WriteLine(); + throw; + } + } + + public static Config Load(String? path = null) + { + var configFilePath = path ?? DefaultConfigFilePath; + try + { + var jsonString = File.ReadAllText(configFilePath); + return Deserialize(jsonString)!; + } + catch (Exception e) + { + $"Failed to read config file {configFilePath}, using default config\n{e}".WriteLine(); + return Default; + } + } + + public static async Task LoadAsync(String? path = null) + { + var configFilePath = path ?? DefaultConfigFilePath; + try + { + var jsonString = await File.ReadAllTextAsync(configFilePath); + return Deserialize(jsonString)!; + } + catch (Exception e) + { + Console.WriteLine($"Couldn't read config file {configFilePath}, using default config"); + e.Message.WriteLine(); + return Default; + } + } +} diff --git a/csharp/App/KacoCommunication/SystemConfig/DeviceConfig.cs b/csharp/App/KacoCommunication/SystemConfig/DeviceConfig.cs new file mode 100644 index 000000000..6d4ef9e00 --- /dev/null +++ b/csharp/App/KacoCommunication/SystemConfig/DeviceConfig.cs @@ -0,0 +1,13 @@ +using InnovEnergy.App.KacoCommunication.Devices; + +namespace InnovEnergy.App.KacoCommunication.SystemConfig; + +public class DeviceConfig +{ + public required SalimaxDevice KacoIp { get; init; } + public required SalimaxDevice DcDcIp { get; init; } + public required SalimaxDevice GridMeterIp { get; init; } + + public required Int32[] BatteryNodes { get; init; } + +} diff --git a/csharp/App/KacoCommunication/SystemConfig/S3Config.cs b/csharp/App/KacoCommunication/SystemConfig/S3Config.cs new file mode 100644 index 000000000..702f774a6 --- /dev/null +++ b/csharp/App/KacoCommunication/SystemConfig/S3Config.cs @@ -0,0 +1,79 @@ +using System.Security.Cryptography; +using Flurl; +using Flurl.Http; +using InnovEnergy.Lib.Utils; +using static System.Text.Encoding; +using Convert = System.Convert; + +namespace InnovEnergy.App.KacoCommunication.SystemConfig; + +public record S3Config +{ + public required String Bucket { get; init; } + public required String Region { get; init; } + public required String Provider { get; init; } + public required String Key { get; init; } + public required String Secret { get; init; } + public required String ContentType { get; init; } + + private String Host => $"{Bucket}.{Region}.{Provider}"; + private String Url => $"https://{Host}"; + + public IFlurlRequest CreatePutRequest(String s3Path) => CreateRequest("PUT", s3Path); + public IFlurlRequest CreateGetRequest(String s3Path) => CreateRequest("GET", s3Path); + + private IFlurlRequest CreateRequest(String method, String s3Path) + { + var date = DateTime.UtcNow.ToString("r"); + var auth = CreateAuthorization(method, s3Path, date); + + return Url + .AppendPathSegment(s3Path) + .WithHeader("Host", Host) + .WithHeader("Date", date) + .WithHeader("Authorization", auth) + .AllowAnyHttpStatus(); + } + + private String CreateAuthorization(String method, + String s3Path, + String date) + { + return CreateAuthorization + ( + method : method, + bucket : Bucket, + s3Path : s3Path, + date : date, + s3Key : Key, + s3Secret : Secret, + contentType: ContentType + ); + } + + + + private static String CreateAuthorization(String method, + String bucket, + String s3Path, + String date, + String s3Key, + String s3Secret, + String contentType = "application/base64", + String md5Hash = "") + { + + contentType = "application/base64; charset=utf-8"; + //contentType = "text/plain; charset=utf-8"; //this to use when sending plain csv to S3 + + var payload = $"{method}\n{md5Hash}\n{contentType}\n{date}\n/{bucket.Trim('/')}/{s3Path.Trim('/')}"; + using var hmacSha1 = new HMACSHA1(UTF8.GetBytes(s3Secret)); + + var signature = UTF8 + .GetBytes(payload) + .Apply(hmacSha1.ComputeHash) + .Apply(Convert.ToBase64String); + + return $"AWS {s3Key}:{signature}"; + } +} \ No newline at end of file diff --git a/csharp/App/KacoCommunication/Topology.cs b/csharp/App/KacoCommunication/Topology.cs new file mode 100644 index 000000000..3f6dce2ad --- /dev/null +++ b/csharp/App/KacoCommunication/Topology.cs @@ -0,0 +1,265 @@ +using System.Globalization; +using InnovEnergy.App.KacoCommunication.Devices; +using InnovEnergy.App.KacoCommunication.ESS; +using InnovEnergy.Lib.Devices.BatteryDeligreen; +using InnovEnergy.Lib.Units.Power; +using InnovEnergy.Lib.Utils; + +namespace InnovEnergy.App.KacoCommunication; + + +// +// ┌────┐ +// │ Pv │ +// └────┘ +// V +// V +// (i) 13.2 kW ┌────────────┐ ┌────────────┐ ┌────────────┐ +// V │ Battery K1│ │ Battery K2│ │ Battery K3│ +// ┌─────────┐ ┌─────────┐ V ├────────────┤ ├────────────┤ ├────────────┤ +// │ Grid │ │ AC/DC │ ┌────────┐ ┌───────┐ │ 52.3 V │ │ 52.3 V │ │ 52.3 V │ +// ├─────────┤ -10.3 kW├─────────┤ -11.7 kW │ Dc Bus │ 1008 W │ DC/DC │ 1008 W │ 99.1 % │ │ 99.1 % │ │ 99.1 % │ +// │ -3205 W │<<<<<<<<<│ -6646 W │<<<<<<<<<<├────────┤>>>>>>>>>>├───────┤>>>>>>>>>>│ 490 mA │ │ 490 mA │ │ 490 mA │ +// │ -3507 W │ (a) │ -5071 W │ (h) │ 776 V │ (k) │ 56 V │ (l) │ 250 °C │ │ 250 °C │ │ 250 °C │ +// │ -3605 W │ └─────────┘ └────────┘ └───────┘ │ 445 A │ │ 445 A │ │ 445 A │ +// └─────────┘ V │ │ │ │ │ │ +// V │ │ │ │ │ │ +// (j) 0 W └────────────┘ └────────────┘ └────────────┘ +// V +// V +// ┌──────┐ +// │ Load │ +// └──────┘ +// + +// New (simplified) topology: +// +// ┌────┐ +// │ PV │ +// └────┘ +// V +// V +// (i) 13.2 kW +// V +// V +// ┌─────────┐ ┌─────────┐ (h) ┌────────┐ (k) ┌───────┐ (l) ┌────────────┐ ┌────────────┐ ┌────────────┐ +// │ Grid │<<<│ AC/DC │<<<<<<<<<<<<<│ Dc Bus │>>>>>>>>│ DC/DC │>>>>>>>>│ Battery K1 │ │ Battery K2 │ │ Battery K3 │ +// ├─────────┤ ├─────────┤ ├────────┤ ├───────┤ ├────────────┤ ├────────────┤ ├────────────┤ +// │ -3205 W │ │ -6646 W │ │ 776 V │ │ 56 V │ │ 52.3 V ... │ │ 52.3 V ... │ │ 52.3 V ... │ +// │ -3507 W │ │ -5071 W │ └────────┘ └───────┘ └────────────┘ └────────────┘ └────────────┘ +// │ -3605 W │ +// └─────────┘ +// +// V +// V +// (j) 0 W +// V +// V +// ┌──────┐ +// │ Load │ +// └──────┘ +// +// Notes: +// - (a) is grid power (to/from grid) +// - (h) is AC/DC -> DC link power (or your chosen link variable) +// - (i) PV -> DC bus +// - (j) DC load +// - (k) DC bus -> DC/DC link +// - (l) DC/DC -> battery power (or total battery power) + +public static class SimpleTopology +{ + public static TextBlock CreateSimpleTopologyTextBlock(this StatusRecord status) + { + // Keep the same variable letters as your diagrams (where possible) + var a = status.GridMeterRecord?.ActivePowerTotal; + + // In your existing code, "AC/DC column" shows per-device AC power; + // and "h" is a separate link (AcDcToDcLink?.Power.Value). + var h = 0; + + var i = 0; + var j = 0; + + var k = 0; + + // You mentioned this changed: l is now equal total battery power + var l = status.BatteryKabinet1.Power; + + var grid = status.CreateGridColumn(a); + var acdc = status.CreateAcDcColumn(h); + var dcBus = status.CreateDcBusColumn(i, j, k); + var dcdc = status.CreateDcDcColumn(l); + var batteries = status.CreateBatteriesRow(); + + return TextBlock.AlignCenterVertical( + grid, + acdc, + dcBus, + dcdc, + batteries + ); + } + + private static TextBlock CreateGridColumn(this StatusRecord status, ActivePower? a) + { + // ┌─────────┐ + // │ Grid │ + // ├─────────┤ + // │ L1 P │ + // │ L2 P │ + // │ L3 P │ + // └─────────┘ (a) flow to AC/DC + + var gridMeterAc = status.GridMeterRecord; + + var gridBox = TextBlock + .AlignLeft( + gridMeterAc?.ActivePowerL1.Value.ToString(CultureInfo.InvariantCulture) ?? "???", + gridMeterAc?.ActivePowerL2.Value.ToString(CultureInfo.InvariantCulture) ?? "???", + gridMeterAc?.ActivePowerL3.Value.ToString(CultureInfo.InvariantCulture) ?? "???" + ) + .TitleBox("Grid"); + + // Flow from Grid to AC/DC in the picture is horizontal (left -> right), using <<<<<< for export/import. + // Your Flow.Horizontal(power) already handles arrow direction by sign (based on your existing outputs). + var flow = Flow.Horizontal(a); + + return TextBlock.AlignCenterVertical(gridBox, flow); + } + + private static TextBlock CreateAcDcColumn(this StatusRecord status, ActivePower? h) + { + // ┌─────────┐ + // │ AC/DC │ + // ├─────────┤ + // │ dev1 P │ + // │ dev2 P │ + // └─────────┘ (h) flow to DC Bus + + var acdcBox = TextBlock + .AlignLeft(status.InverterRecord?.ActivePowerSetPercent.ToString() ?? "???") + .TitleBox("AC/DC"); + + var flowToDcBus = Flow.Horizontal(h); + + return TextBlock.AlignCenterVertical(acdcBox, flowToDcBus); + } + + private static TextBlock CreateDcBusColumn( + this StatusRecord status, + ActivePower? i, + ActivePower? j, + ActivePower? k) + { + // ┌────┐ + // │ PV │ + // └────┘ + // V + // (i) 13.2 kW + // V + // ┌────────┐ (k) >>>>>>>>> to DC/DC + // │ Dc Bus │>>>>>>>>>>>>>>>>>>> + // ├────────┤ + // │ 776 V │ + // └────────┘ + // V + // (j) 0 W + // V + // ┌──────┐ + // │ Load │ + // └──────┘ + + // PV box + vertical flow + var pvBox = TextBlock.FromString("PV").Box(); + var pvToBus = Flow.Vertical(i); + + // DC bus box (voltage from your DcDc record matches your existing code) + var dcBusVoltage = 0.0; + var dcBusBox = dcBusVoltage + .ToString(CultureInfo.InvariantCulture) + .Apply(TextBlock.FromString) + .TitleBox("Dc Bus"); + + // Horizontal flow from DC Bus to DC/DC + var busToDcDc = Flow.Horizontal(k); + + // Load box + vertical flow + var busToLoad = Flow.Vertical(j); + var loadBox = TextBlock.FromString("Load").Box(); + + // Assemble: put PV above DC Bus, Load below DC Bus, and the (k) flow beside the bus. + return TextBlock.AlignCenterVertical( + TextBlock.AlignCenterHorizontal(pvBox, pvToBus, dcBusBox, busToLoad, loadBox), + busToDcDc + ); + } + + private static TextBlock CreateDcDcColumn(this StatusRecord status, ActivePower? l) + { + // ┌───────┐ + // │ DC/DC │ + // ├───────┤ + // │ 56 V │ + // └───────┘ (l) flow to batteries + + var dc48Voltage =0.0; + + var dcdcBox = TextBlock + .AlignLeft(dc48Voltage) + .TitleBox("DC/DC"); + + var flowToBattery = Flow.Horizontal(l); + + return TextBlock.AlignCenterVertical(dcdcBox, flowToBattery); + } + + private static TextBlock CreateBatteriesRow(this StatusRecord status) + { + // Battery K1 | Battery K2 | Battery K3 (side-by-side) + // Each box: voltage, soc, current, temp, etc. (you can tailor) + + var bat = status.BatteryKabinet1; + if (bat is null) + return TextBlock.AlignLeft("no battery").Box(); + + // If you actually have relay names K1/K2/K3 per battery, wire them here. + // For now we label by index as "Battery K{n}" to match your picture. + var boxes = bat.Devices + .Select((b, idx) => CreateBatteryKBox(b, idx)) + .ToReadOnlyList(); + + // Align horizontally to match the diagram + return boxes.Any() + ? TextBlock.AlignTop(boxes) + : TextBlock.AlignLeft("no battery devices").Box(); + } + + private static TextBlock CreateBatteryKBox(BatteryDeligreenRecord battery, int idx) + { + // Minimal “K-style” battery box matching your diagram fields + var data = battery.BatteryDeligreenDataRecord; + + // Some of your sample screen values look like: + // 52.3 V, 99.1 %, 490 mA, 250 °C, 445 A + // Map these to whatever fields you trust in your record. + var voltage = data.BusVoltage.ToDisplayString(); + var soc = data.Soc.ToDisplayString(); + var current = data.BusCurrent.ToDisplayString(); + var temp = data.TemperaturesList.PowerTemperature.ToDisplayString(); + + // If you have a better “pack current” field, replace this line. + // Keeping it as a separate line to mimic the picture’s extra current-like line. + var extraCurrent = data.BusCurrent.ToDisplayString(); + + return TextBlock + .AlignLeft( + voltage, + soc, + current, + temp, + extraCurrent + ) + .TitleBox($"Battery K{idx + 1}"); + } +} diff --git a/csharp/Lib/Devices/Kaco92L3/DataType/ControlMode.cs b/csharp/Lib/Devices/Kaco92L3/DataType/ControlMode.cs new file mode 100644 index 000000000..72fea9a6d --- /dev/null +++ b/csharp/Lib/Devices/Kaco92L3/DataType/ControlMode.cs @@ -0,0 +1,14 @@ +namespace InnovEnergy.Lib.Devices.Kaco92L3.DataType; + +public enum ControlMode +{ + RpcLocal = 0, + RpcRemote = 1 +} + + +public enum EnableDisableEnum : ushort +{ + Disabled = 0, + Enabled = 1 +} \ No newline at end of file diff --git a/csharp/Lib/Devices/Kaco92L3/DataType/CurrentState.cs b/csharp/Lib/Devices/Kaco92L3/DataType/CurrentState.cs new file mode 100644 index 000000000..8d5d11d17 --- /dev/null +++ b/csharp/Lib/Devices/Kaco92L3/DataType/CurrentState.cs @@ -0,0 +1,17 @@ +namespace InnovEnergy.Lib.Devices.Kaco92L3.DataType; + +public enum CurrentState +{ + Off = 1, + Sleeping = 2, + Starting = 3, + Mppt = 4, + Throttled = 5, + ShuttingDown = 6, + Fault = 7, + Standby = 8, + Precharge = 9, + GridPreConnected = 10, + GridConnected = 11, + NoErrorPending = 12 +} \ No newline at end of file diff --git a/csharp/Lib/Devices/Kaco92L3/DataType/ErrorPcu.cs b/csharp/Lib/Devices/Kaco92L3/DataType/ErrorPcu.cs new file mode 100644 index 000000000..77de80505 --- /dev/null +++ b/csharp/Lib/Devices/Kaco92L3/DataType/ErrorPcu.cs @@ -0,0 +1,16 @@ +namespace InnovEnergy.Lib.Devices.Kaco92L3.DataType; + +public enum ErrorPcu +{ + NoEvent = 0, + OverTemp = 1, + OverVolt = 2, + UnderVolt = 3, + BattPolIncorrect = 4, + CounterTooHigh = 5, + DuringPrecharge = 6, + BattVoltOutOfRange = 7, + I2CComm = 8, + CanComm = 9, + SwitchOffAcDsp = 10, +} \ No newline at end of file diff --git a/csharp/Lib/Devices/Kaco92L3/DataType/ReuqestedState.cs b/csharp/Lib/Devices/Kaco92L3/DataType/ReuqestedState.cs new file mode 100644 index 000000000..8e4ab2639 --- /dev/null +++ b/csharp/Lib/Devices/Kaco92L3/DataType/ReuqestedState.cs @@ -0,0 +1,9 @@ +namespace InnovEnergy.Lib.Devices.Kaco92L3.DataType; + +public enum ReuqestedState +{ + Off = 1, + Standby = 8, + GridPreConnected = 10, + GridConnected = 11, +} \ No newline at end of file diff --git a/csharp/Lib/Devices/Kaco92L3/DataType/StatePcu.cs b/csharp/Lib/Devices/Kaco92L3/DataType/StatePcu.cs new file mode 100644 index 000000000..a341ffd70 --- /dev/null +++ b/csharp/Lib/Devices/Kaco92L3/DataType/StatePcu.cs @@ -0,0 +1,14 @@ +namespace InnovEnergy.Lib.Devices.Kaco92L3.DataType; + +public enum StatePcu +{ + WaitForStartup = 1, + Standby = 2, + SwitchRelMinus = 3, + SwitchRelPrecharge = 4, + SwitchRelPlus = 5, + Running = 6, + Cooldown = 7, + Error = 8, + ClearError = 9, +} \ No newline at end of file diff --git a/csharp/Lib/Devices/Kaco92L3/Kaco92L3.csproj b/csharp/Lib/Devices/Kaco92L3/Kaco92L3.csproj new file mode 100644 index 000000000..889d35e56 --- /dev/null +++ b/csharp/Lib/Devices/Kaco92L3/Kaco92L3.csproj @@ -0,0 +1,17 @@ + + + + net6.0 + enable + enable + InnovEnergy.Lib.Devices.Kaco92L3; + preview + + + + + + + + + diff --git a/csharp/Lib/Devices/Kaco92L3/KacoDevice.cs b/csharp/Lib/Devices/Kaco92L3/KacoDevice.cs new file mode 100644 index 000000000..22b13ba37 --- /dev/null +++ b/csharp/Lib/Devices/Kaco92L3/KacoDevice.cs @@ -0,0 +1,49 @@ +using InnovEnergy.Lib.Protocols.Modbus.Channels; +using InnovEnergy.Lib.Protocols.Modbus.Clients; +using InnovEnergy.Lib.Protocols.Modbus.Slaves; +using InnovEnergy.Lib.Utils; + +namespace InnovEnergy.Lib.Devices.Kaco92L3; + + +public class KacoDevice: ModbusDevice +{ + public KacoDevice(String hostname, UInt16 port = 502, Byte slaveId = 1) : this(new TcpChannel(hostname, port), slaveId) + { + } + + public KacoDevice(Channel channel, Byte slaveId = 1) : base(new ModbusTcpClient(channel, slaveId)) + { + } + + public KacoDevice(ModbusClient client) : base(client) + { + } + + public new KacoRecord? Read() + { + try + { + return base.Read(); + } + catch + { + "Failed to read data from Kaco".WriteLine(); + return null; + } + } + + + public new void Write(KacoRecord registers) + { + try + { + base.Write(registers); + } + catch (Exception e) + { + // TODO: Log + Console.WriteLine(e); + } + } +} \ No newline at end of file diff --git a/csharp/Lib/Devices/Kaco92L3/KacoRecord.Api.cs b/csharp/Lib/Devices/Kaco92L3/KacoRecord.Api.cs new file mode 100644 index 000000000..c88766213 --- /dev/null +++ b/csharp/Lib/Devices/Kaco92L3/KacoRecord.Api.cs @@ -0,0 +1,255 @@ +using InnovEnergy.Lib.Devices.Kaco92L3.DataType; + +namespace InnovEnergy.Lib.Devices.Kaco92L3; + +public partial class KacoRecord +{ + + private static float ScaleSunspec(Int16 value, Int16 sf) + { + // -32768 is SunSpec "not implemented" + if (value == -32768) + return float.NaN; + + if (sf == 0) + return value; + + return (float)(value * Math.Pow(10, sf)); + } + + private static float ScaleSunspec(UInt16 value, Int16 sf) + { + // interpret unsigned as signed when applying SF; range is the same + return ScaleSunspec(unchecked((Int16)value), sf); + } + + private static Int16 UnscaleSunspecToInt16(float value, Int16 sf) + { + if (float.IsNaN(value) || float.IsInfinity(value)) + return -32768; // "not implemented" / invalid + + if (sf == 0) + return (Int16)Math.Round(value); + + var raw = value / (float)Math.Pow(10, sf); + return (Int16)Math.Round(raw); + } + + private static UInt16 UnscaleSunspecToUInt16(float value, Int16 sf) + { + var raw = UnscaleSunspecToInt16(value, sf); + return unchecked((UInt16)raw); + } + + /****************************** High-level API for Model 64201 ****************************/ + + // ─────────────────────────────────────────────── + // States & control + // ─────────────────────────────────────────────── + + // Header + public UInt16 BattCharId => _battCharId; // ID = 64202 + public UInt16 BattCharLength => _battCharLength; // L = 6 + (RBCount * 8) + + public ReuqestedState RequestedState + { + get => (ReuqestedState)_requestedState; + set => _requestedState = (UInt16)value; + } + + public CurrentState CurrentState => (CurrentState)_currentState; + + public ControlMode ControlMode + { + get => (ControlMode)_controlMode; + set => _controlMode = (UInt16)value; + } + + public bool IsGridConnected => CurrentState == CurrentState.GridConnected; + public bool IsStandby => CurrentState == CurrentState.Standby; + public bool IsOff => CurrentState == CurrentState.Off; + + // Watchdog seconds (no scale factor) + public UInt16 WatchdogSeconds + { + get => _watchdog; + set => _watchdog = value; + } + + + // ─────────────────────────────────────────────── + // Setpoints (scaled) + // ─────────────────────────────────────────────── + + /// Active power setpoint in percent of WMax [%]. + public float ActivePowerSetPercent + { + get => ScaleSunspec(_wSetPct, _wSetPctSf); + set => _wSetPct = UnscaleSunspecToInt16(value, _wSetPctSf); + } + + /// Reactive power setpoint in percent of SMax [%]. + public float ReactivePowerSetPercent + { + get => ScaleSunspec(_varWMaxSetPct, _varSetPctSf); + set => _varWMaxSetPct = UnscaleSunspecToInt16(value, _varSetPctSf); + } + + // ─────────────────────────────────────────────── + // Ramp parameters (scaled) + // ─────────────────────────────────────────────── + + /// Active power PT1 ramp time [s]. + public float ActivePowerRampTimeSeconds + { + get => ScaleSunspec(_wParamRmpTms, _rmpTmsSf); + set => _wParamRmpTms = UnscaleSunspecToUInt16(value, _rmpTmsSf); + } + + /// Active power ramp-down rate [% ref / min]. + public float ActivePowerRampDownPercentPerMin + { + get => ScaleSunspec(_wParamRmpDecTmn, _rmpIncDecSf); + set => _wParamRmpDecTmn = UnscaleSunspecToUInt16(value, _rmpIncDecSf); + } + + /// Active power ramp-up rate [% ref / min]. + public float ActivePowerRampUpPercentPerMin + { + get => ScaleSunspec(_wParamRmpIncTmn, _rmpIncDecSf); + set => _wParamRmpIncTmn = UnscaleSunspecToUInt16(value, _rmpIncDecSf); + } + + /// Reactive power PT1 ramp time [s]. + public float ReactivePowerRampTimeSeconds + { + get => ScaleSunspec(_varParamRmpTms, _rmpTmsSf); + set => _varParamRmpTms = UnscaleSunspecToUInt16(value, _rmpTmsSf); + } + + /// Reactive power ramp-down rate [% ref / min]. + public float ReactivePowerRampDownPercentPerMin + { + get => ScaleSunspec(_varParamRmpDecTmn, _rmpIncDecSf); + set => _varParamRmpDecTmn = UnscaleSunspecToUInt16(value, _rmpIncDecSf); + } + + /// Reactive power ramp-up rate [% ref / min]. + public float ReactivePowerRampUpPercentPerMin + { + get => ScaleSunspec(_varParamRmpIncTmn, _rmpIncDecSf); + set => _varParamRmpIncTmn = UnscaleSunspecToUInt16(value, _rmpIncDecSf); + } + + // Ramp enable flags + public EnableDisableEnum ActivePowerRampEnable + { + get => (EnableDisableEnum)_wParamEna; + set => _wParamEna = (UInt16)value; + } + + public EnableDisableEnum ReactivePowerRampEnable + { + get => (EnableDisableEnum)_varParamEna; + set => _varParamEna = (UInt16)value; + } + + // ─────────────────────────────────────────────── + // Status / error (read-only enum views) + // ─────────────────────────────────────────────── + + //public VendorStateEnum VendorState => (VendorStateEnum)_stVnd; + //public PuStateEnum PuState => (PuStateEnum)_stPu; + public StatePcu PcuState => (StatePcu)_stPcu; + public ErrorPcu PcuError => (ErrorPcu)_errPcu; + + public UInt16 BatteryCharVersion => _battCharVersion; + public UInt16 BatteryCharMinorVersion => _battCharVerMinor; + + /// + /// Scale factor for battery voltages (V_SF). + /// + public Int16 BatteryVoltageScaleFactor => _battCharVSf; + + /// + /// Scale factor for battery currents (A_SF). + /// + public Int16 BatteryCurrentScaleFactor => _battCharASf; + + // Helper wrappers for scaled values + private float ScaleBattVoltage(UInt16 raw) => ScaleSunspec(raw, _battCharVSf); + private float ScaleBattCurrent(UInt16 raw) => ScaleSunspec(raw, _battCharASf); + private UInt16 UnscaleBattVoltage(float value) => UnscaleSunspecToUInt16(value, _battCharVSf); + private UInt16 UnscaleBattCurrent(float value) => UnscaleSunspecToUInt16(value, _battCharASf); + + // ─────────────────────────────────────────────── + // Battery discharge limits (scaled, RW) + // ─────────────────────────────────────────────── + + /// Minimum discharge voltage [V]. + public float MinDischargeVoltage + { + get => ScaleBattVoltage(_disMinVRaw); + set => _disMinVRaw = UnscaleBattVoltage(value); + } + + /// Maximum discharge current [A]. + public float MaxDischargeCurrent + { + get => ScaleBattCurrent(_disMaxARaw); + set => _disMaxARaw = UnscaleBattCurrent(value); + } + + /// Discharge cutoff current [A]. If discharge current falls below this, it disconnects (optional according to sheet). + public float DischargeCutoffCurrent + { + get => ScaleBattCurrent(_disCutoffARaw); + set => _disCutoffARaw = UnscaleBattCurrent(value); + } + + // ─────────────────────────────────────────────── + // Battery charge limits (scaled, RW) + // ─────────────────────────────────────────────── + + /// Maximum charge voltage [V]. + public float MaxChargeVoltage + { + get => ScaleBattVoltage(_chaMaxVRaw); + set => _chaMaxVRaw = UnscaleBattVoltage(value); + } + + /// Maximum charge current [A]. + public float MaxChargeCurrent + { + get => ScaleBattCurrent(_chaMaxARaw); + set => _chaMaxARaw = UnscaleBattCurrent(value); + } + + /// Charge cutoff current [A]. If charge current falls below this, it disconnects. + public float ChargeCutoffCurrent + { + get => ScaleBattCurrent(_chaCutoffARaw); + set => _chaCutoffARaw = UnscaleBattCurrent(value); + } + + // ─────────────────────────────────────────────── + // Limit enable flag + // ─────────────────────────────────────────────── + + /// + /// When EnLimit = 1, new battery limits are activated. + /// + public EnableDisableEnum BatteryLimitsEnable + { + get => (EnableDisableEnum)_enLimitRaw; + set => _enLimitRaw = (UInt16)value; + } + + /// Convenience bool wrapper for EnLimit. + public bool BatteryLimitsEnabled + { + get => BatteryLimitsEnable == EnableDisableEnum.Enabled; + set => BatteryLimitsEnable = value ? EnableDisableEnum.Enabled : EnableDisableEnum.Disabled; + } + +} \ No newline at end of file diff --git a/csharp/Lib/Devices/Kaco92L3/KacoRecord.modbus.cs b/csharp/Lib/Devices/Kaco92L3/KacoRecord.modbus.cs new file mode 100644 index 000000000..f164ed577 --- /dev/null +++ b/csharp/Lib/Devices/Kaco92L3/KacoRecord.modbus.cs @@ -0,0 +1,110 @@ +using System.Diagnostics.CodeAnalysis; +using InnovEnergy.Lib.Protocols.Modbus.Reflection.Attributes; +using InnovEnergy.Lib.Units; +using InnovEnergy.Lib.Units.Power; + +namespace InnovEnergy.Lib.Devices.Kaco92L3; + +[AddressOffset(-1)] +public partial class KacoRecord +{ + /****************************** Holding registers – SunSpec Model 64201 ****************************/ + + // Model 64201 header + [HoldingRegister(41061)] private UInt16 _model64201Id; // 0xA065 (DID = 64201) + [HoldingRegister(41062)] private UInt16 _model64201Length; // 0xA066 (L = 52) + + // Version info + [HoldingRegister(41063)] private UInt16 _version; // 0xA067 + [HoldingRegister(41064)] private UInt16 _versionMinor; // 0xA068 + + // State control + [HoldingRegister(41065, writable: true)] private UInt16 _requestedState; // 0xA069 – RequestedState (enum16, RW) + [HoldingRegister(41066)] private UInt16 _currentState; // 0xA06A – CurrentState (enum16, R) + [HoldingRegister(41067, writable: true)] private UInt16 _controlMode; // 0xA06B – ControlMode (enum16, RW) + [HoldingRegister(41068)] private UInt16 _reserved7; // 0xA06C – Reserved + + // Watchdog / setpoints + //Enable Watchdog countdown. Register must be filled with the desired watchdog timeout in seconds. 0 means watchdog is disabled. It is recommended to re-write the register at least 10 seconds before the timeout is activated. + [HoldingRegister(41069, writable: true)] private UInt16 _watchdog; // 0xA06D – Watchdog (uint16, RW, seconds) 0 to 600 + [HoldingRegister (41070, writable: true)] private Int16 _wSetPct; // 0xA06E – WSetPct (int16, RW, %WMax) unscaled: -100 to 100. Set power output to specified level. + [HoldingRegister (41071, writable: true)] private Int16 _varWMaxSetPct; // 0xA06F – VarWMaxSetPct (int16, RW, %SMax) unscaled: -100 to 100 + + // Reserved padding + [HoldingRegister(41072)] private UInt16 _reserved11; // 0xA070 + [HoldingRegister(41073)] private UInt16 _reserved12; // 0xA071 + [HoldingRegister(41074)] private UInt16 _reserved13; // 0xA072 + [HoldingRegister(41075)] private UInt16 _reserved14; // 0xA073 + [HoldingRegister(41076)] private UInt16 _reserved15; // 0xA074 + [HoldingRegister(41077)] private UInt16 _reserved16; // 0xA075 + + // Status / error + [HoldingRegister(41078)] private UInt16 _stVnd; // 0xA076 – StVnd (enum16, R) PrologState + [HoldingRegister(41079)] private UInt16 _stPu; // 0xA077 – StPu (enum16, R) Power Unit State (DSP) + [HoldingRegister(41080)] private UInt16 _stPcu; // 0xA078 – StPcu (enum16, R) pcu state + [HoldingRegister(41081)] private UInt16 _errPcu; // 0xA079 – ErrPcu (enum16, R) pcu error + + // Active power ramp parameters + [HoldingRegister(41082, writable: true)] private UInt16 _wParamRmpTms; // 0xA07A – WParamRmpTms (uint16, RW, s) + [HoldingRegister(41083, writable: true)] private UInt16 _wParamRmpDecTmn; // 0xA07B – WParamRmpDecTmn (uint16, RW, %ref/min) + [HoldingRegister(41084, writable: true)] private UInt16 _wParamRmpIncTmn; // 0xA07C – WParamRmpIncTmn (uint16, RW, %ref/min) + + [HoldingRegister(41085)] private UInt16 _reserved24; // 0xA07D – Reserved + [HoldingRegister(41086)] private UInt16 _reserved25; // 0xA07E – Reserved + + [HoldingRegister(41087, writable: true)] private UInt16 _wParamEna; // 0xA07F – WParam_Ena (enum16, RW) WSet_Ena control 0 or 1 + + // Reactive power ramp parameters + [HoldingRegister(41088, writable: true)] private UInt16 _varParamRmpTms; // 0xA080 – VarParamRmpTms (uint16, RW, s) + [HoldingRegister(41089, writable: true)] private UInt16 _varParamRmpDecTmn; // 0xA081 – VarParamRmpDecTmn (uint16, RW, %ref/min) + [HoldingRegister(41090, writable: true)] private UInt16 _varParamRmpIncTmn; // 0xA082 – VarParamRmpIncTmn (uint16, RW, %ref/min) + + [HoldingRegister(41091)] private UInt16 _reserved30; // 0xA083 – Reserved + [HoldingRegister(41092)] private UInt16 _reserved31; // 0xA084 – Reserved + + [HoldingRegister(41093, writable: true)] private UInt16 _varParamEna; // 0xA085 – VarParam_Ena (enum16, RW) Enumerated valued. Percent limit VAr enable/disable control. + + // Measurements (read-only) + [HoldingRegister(41094)] private UInt16 _phaseVoltageAN; // 0xA086 – PhVphA (uint16, R, V, V_SF) + [HoldingRegister(41095)] private UInt16 _phaseVoltageBN; // 0xA087 – PhVphB (uint16, R, V, V_SF) + [HoldingRegister(41096)] private UInt16 _phaseVoltageCN; // 0xA088 – PhVphC (uint16, R, V, V_SF) + + [HoldingRegister (41097)] private Int16 _activePowerW; // 0xA089 – W (int16, R, W, W_SF) + [HoldingRegister (41098)] private Int16 _reactivePowerVar; // 0xA08A – VAR (int16, R, var, Var_SF) + [HoldingRegister (41099)] private Int16 _lineFrequencyHz; // 0xA08B – Hz (int16, R, Hz, Hz_SF) + + // Scale factors (SunSpec sunsf) + // Scale factor for active power percent. + [HoldingRegister(41107)] private Int16 _wSetPctSf; // 0xA0F3 – WSetPct_SF + // Scale factor for reactive power percent. + [HoldingRegister(41108)] private Int16 _varSetPctSf; // 0xA0F4 – VarSetPct_SF + // Scale factor for PT1 (ramp time). + [HoldingRegister(41109)] private Int16 _rmpTmsSf; // 0xA0F5 – RmpTms_SF + // Scale factor for increment and decrement ramps. + [HoldingRegister(41110)] private Int16 _rmpIncDecSf; // 0xA0F6 – RmpIncDec_SF + + // Header + [HoldingRegister(41115)] private UInt16 _battCharId; // ID = 64202 + [HoldingRegister(41116)] private UInt16 _battCharLength; // L = 6 + (RBCount * 8) + + // Fixed block + [HoldingRegister(41117)] private UInt16 _battCharVersion; // Version (uint16, R) + [HoldingRegister(41118)] private UInt16 _battCharVerMinor; // VerMinor (uint16, R) + [HoldingRegister(41119)] private UInt16 _battCharRsrvd1; // Rsrvd_1 (pad) + [HoldingRegister(41120)] private UInt16 _battCharRsrvd2; // Rsrvd_2 (pad) + [HoldingRegister (41121)] private Int16 _battCharVSf; // V_SF (sunsf, R) + [HoldingRegister (41122)] private Int16 _battCharASf; // A_SF (sunsf, R) + + // Repeating block #0 (you said there is only one block) + [HoldingRegister(41123, writable: true)] private UInt16 _disMinVRaw; // DisMinV (uint16, V, V_SF, RW) min. discharge voltage + [HoldingRegister(41124, writable: true)] private UInt16 _disMaxARaw; // DisMaxA (uint16, A, A_SF, RW)max. discharge current + [HoldingRegister(41125, writable: true)] private UInt16 _disCutoffARaw; // DisCutoffA (uint16, A, A_SF, RW)Disconnect if discharge current lower than DisCutoffA + [HoldingRegister(41126, writable: true)] private UInt16 _chaMaxVRaw; // ChaMaxV (uint16, V, V_SF, RW)max. charge voltage + [HoldingRegister(41127, writable: true)] private UInt16 _chaMaxARaw; // ChaMaxA (uint16, A, A_SF, RW)max. charge current + [HoldingRegister(41128, writable: true)] private UInt16 _chaCutoffARaw; // ChaCutoffA (uint16, A, A_SF, RW)Disconnect if charge current lower than ChaCuttoffA + [HoldingRegister(41129)] private UInt16 _battCharPad; // Pad (pad, R) + [HoldingRegister(41130, writable: true)] private UInt16 _enLimitRaw; // EnLimit (uint16, RW)new battery limits are activated when EnLimit is 1 + + + +}