Create Kaco project
This commit is contained in:
parent
bd572b4ed1
commit
2cf5ed4b95
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>(TState state) where TState : notnull => throw new NotImplementedException();
|
||||
|
||||
public Boolean IsEnabled(LogLevel logLevel) => true; // Enable logging for all levels
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception, String> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T>(this T t) where T : notnull
|
||||
{
|
||||
_logger.LogInformation(t.ToString()); // TODO: check warning
|
||||
return t;
|
||||
}
|
||||
|
||||
public static T LogDebug<T>(this T t) where T : notnull
|
||||
{
|
||||
// _logger.LogDebug(t.ToString()); // TODO: check warning
|
||||
return t;
|
||||
}
|
||||
|
||||
public static T LogError<T>(this T t) where T : notnull
|
||||
{
|
||||
// _logger.LogError(t.ToString()); // TODO: check warning
|
||||
return t;
|
||||
}
|
||||
|
||||
public static T LogWarning<T>(this T t) where T : notnull
|
||||
{
|
||||
// _logger.LogWarning(t.ToString()); // TODO: check warning
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace InnovEnergy.App.KacoCommunication.DataTypes;
|
||||
|
||||
public enum SodistoreAlarmState
|
||||
{
|
||||
Green,
|
||||
Orange,
|
||||
Red
|
||||
}
|
||||
|
|
@ -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<AlarmOrWarning>? Warnings { get; set; }
|
||||
public List<AlarmOrWarning>? Alarms { get; set; }
|
||||
}
|
||||
|
||||
public enum MessageType
|
||||
{
|
||||
AlarmOrWarning,
|
||||
Heartbit
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace InnovEnergy.App.KacoCommunication.Devices;
|
||||
|
||||
public enum DeviceState
|
||||
{
|
||||
Disabled,
|
||||
Measured,
|
||||
Computed
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using InnovEnergy.Lib.Utils.Net;
|
||||
|
||||
namespace InnovEnergy.App.KacoCommunication.Devices;
|
||||
|
||||
public class SalimaxDevice : Ip4Address
|
||||
{
|
||||
public required DeviceState DeviceState { get; init; }
|
||||
}
|
||||
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../InnovEnergy.App.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>InnovEnergy.App.KacoCommunication</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Lib\Devices\BatteryDeligreen\BatteryDeligreen.csproj" />
|
||||
<ProjectReference Include="..\..\Lib\Devices\Kaco92L3\Kaco92L3.csproj" />
|
||||
<ProjectReference Include="..\..\Lib\Devices\PLVario2Meter\PLVario2Meter.csproj" />
|
||||
<ProjectReference Include="..\..\Lib\Devices\Trumpf\TruConvertDc\TruConvertDc.csproj" />
|
||||
<ProjectReference Include="..\..\Lib\Units\Units.csproj" />
|
||||
<ProjectReference Include="..\..\Lib\Utils\Utils.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Flurl.Http" Version="4.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="6.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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<Configuration>(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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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<Byte> 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<Byte>())
|
||||
.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<Boolean> 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<String, Object>();
|
||||
|
||||
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<String, Object> 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<Boolean> 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<Boolean> SaveModbusTcpFile(StatusRecord status)
|
||||
{
|
||||
var modbusData = new Dictionary<String, UInt16>();
|
||||
|
||||
// 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<String, UInt16> ConvertToModbusRegisters(Object value, String outputType,
|
||||
Int32 startingAddress)
|
||||
{
|
||||
var registers = new Dictionary<String, UInt16>();
|
||||
|
||||
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<String, Object> 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<String, Object>();
|
||||
}
|
||||
|
||||
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<String, Object>)currentDict[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Config>(jsonString)!;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
$"Failed to read config file {configFilePath}, using default config\n{e}".WriteLine();
|
||||
return Default;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<Config> LoadAsync(String? path = null)
|
||||
{
|
||||
var configFilePath = path ?? DefaultConfigFilePath;
|
||||
try
|
||||
{
|
||||
var jsonString = await File.ReadAllTextAsync(configFilePath);
|
||||
return Deserialize<Config>(jsonString)!;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine($"Couldn't read config file {configFilePath}, using default config");
|
||||
e.Message.WriteLine();
|
||||
return Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace InnovEnergy.Lib.Devices.Kaco92L3.DataType;
|
||||
|
||||
public enum ReuqestedState
|
||||
{
|
||||
Off = 1,
|
||||
Standby = 8,
|
||||
GridPreConnected = 10,
|
||||
GridConnected = 11,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>InnovEnergy.Lib.Devices.Kaco92L3;</RootNamespace>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Protocols\Modbus\Modbus.csproj" />
|
||||
<ProjectReference Include="..\..\Units\Units.csproj" />
|
||||
<ProjectReference Include="..\..\Utils\Utils.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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<KacoRecord>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Active power setpoint in percent of WMax [%].</summary>
|
||||
public float ActivePowerSetPercent
|
||||
{
|
||||
get => ScaleSunspec(_wSetPct, _wSetPctSf);
|
||||
set => _wSetPct = UnscaleSunspecToInt16(value, _wSetPctSf);
|
||||
}
|
||||
|
||||
/// <summary>Reactive power setpoint in percent of SMax [%].</summary>
|
||||
public float ReactivePowerSetPercent
|
||||
{
|
||||
get => ScaleSunspec(_varWMaxSetPct, _varSetPctSf);
|
||||
set => _varWMaxSetPct = UnscaleSunspecToInt16(value, _varSetPctSf);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Ramp parameters (scaled)
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Active power PT1 ramp time [s].</summary>
|
||||
public float ActivePowerRampTimeSeconds
|
||||
{
|
||||
get => ScaleSunspec(_wParamRmpTms, _rmpTmsSf);
|
||||
set => _wParamRmpTms = UnscaleSunspecToUInt16(value, _rmpTmsSf);
|
||||
}
|
||||
|
||||
/// <summary>Active power ramp-down rate [% ref / min].</summary>
|
||||
public float ActivePowerRampDownPercentPerMin
|
||||
{
|
||||
get => ScaleSunspec(_wParamRmpDecTmn, _rmpIncDecSf);
|
||||
set => _wParamRmpDecTmn = UnscaleSunspecToUInt16(value, _rmpIncDecSf);
|
||||
}
|
||||
|
||||
/// <summary>Active power ramp-up rate [% ref / min].</summary>
|
||||
public float ActivePowerRampUpPercentPerMin
|
||||
{
|
||||
get => ScaleSunspec(_wParamRmpIncTmn, _rmpIncDecSf);
|
||||
set => _wParamRmpIncTmn = UnscaleSunspecToUInt16(value, _rmpIncDecSf);
|
||||
}
|
||||
|
||||
/// <summary>Reactive power PT1 ramp time [s].</summary>
|
||||
public float ReactivePowerRampTimeSeconds
|
||||
{
|
||||
get => ScaleSunspec(_varParamRmpTms, _rmpTmsSf);
|
||||
set => _varParamRmpTms = UnscaleSunspecToUInt16(value, _rmpTmsSf);
|
||||
}
|
||||
|
||||
/// <summary>Reactive power ramp-down rate [% ref / min].</summary>
|
||||
public float ReactivePowerRampDownPercentPerMin
|
||||
{
|
||||
get => ScaleSunspec(_varParamRmpDecTmn, _rmpIncDecSf);
|
||||
set => _varParamRmpDecTmn = UnscaleSunspecToUInt16(value, _rmpIncDecSf);
|
||||
}
|
||||
|
||||
/// <summary>Reactive power ramp-up rate [% ref / min].</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Scale factor for battery voltages (V_SF).
|
||||
/// </summary>
|
||||
public Int16 BatteryVoltageScaleFactor => _battCharVSf;
|
||||
|
||||
/// <summary>
|
||||
/// Scale factor for battery currents (A_SF).
|
||||
/// </summary>
|
||||
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)
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Minimum discharge voltage [V].</summary>
|
||||
public float MinDischargeVoltage
|
||||
{
|
||||
get => ScaleBattVoltage(_disMinVRaw);
|
||||
set => _disMinVRaw = UnscaleBattVoltage(value);
|
||||
}
|
||||
|
||||
/// <summary>Maximum discharge current [A].</summary>
|
||||
public float MaxDischargeCurrent
|
||||
{
|
||||
get => ScaleBattCurrent(_disMaxARaw);
|
||||
set => _disMaxARaw = UnscaleBattCurrent(value);
|
||||
}
|
||||
|
||||
/// <summary>Discharge cutoff current [A]. If discharge current falls below this, it disconnects (optional according to sheet).</summary>
|
||||
public float DischargeCutoffCurrent
|
||||
{
|
||||
get => ScaleBattCurrent(_disCutoffARaw);
|
||||
set => _disCutoffARaw = UnscaleBattCurrent(value);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Battery charge limits (scaled, RW)
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Maximum charge voltage [V].</summary>
|
||||
public float MaxChargeVoltage
|
||||
{
|
||||
get => ScaleBattVoltage(_chaMaxVRaw);
|
||||
set => _chaMaxVRaw = UnscaleBattVoltage(value);
|
||||
}
|
||||
|
||||
/// <summary>Maximum charge current [A].</summary>
|
||||
public float MaxChargeCurrent
|
||||
{
|
||||
get => ScaleBattCurrent(_chaMaxARaw);
|
||||
set => _chaMaxARaw = UnscaleBattCurrent(value);
|
||||
}
|
||||
|
||||
/// <summary>Charge cutoff current [A]. If charge current falls below this, it disconnects.</summary>
|
||||
public float ChargeCutoffCurrent
|
||||
{
|
||||
get => ScaleBattCurrent(_chaCutoffARaw);
|
||||
set => _chaCutoffARaw = UnscaleBattCurrent(value);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
// Limit enable flag
|
||||
// ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// When EnLimit = 1, new battery limits are activated.
|
||||
/// </summary>
|
||||
public EnableDisableEnum BatteryLimitsEnable
|
||||
{
|
||||
get => (EnableDisableEnum)_enLimitRaw;
|
||||
set => _enLimitRaw = (UInt16)value;
|
||||
}
|
||||
|
||||
/// <summary>Convenience bool wrapper for EnLimit.</summary>
|
||||
public bool BatteryLimitsEnabled
|
||||
{
|
||||
get => BatteryLimitsEnable == EnableDisableEnum.Enabled;
|
||||
set => BatteryLimitsEnable = value ? EnableDisableEnum.Enabled : EnableDisableEnum.Disabled;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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<UInt16>(41061)] private UInt16 _model64201Id; // 0xA065 (DID = 64201)
|
||||
[HoldingRegister<UInt16>(41062)] private UInt16 _model64201Length; // 0xA066 (L = 52)
|
||||
|
||||
// Version info
|
||||
[HoldingRegister<UInt16>(41063)] private UInt16 _version; // 0xA067
|
||||
[HoldingRegister<UInt16>(41064)] private UInt16 _versionMinor; // 0xA068
|
||||
|
||||
// State control
|
||||
[HoldingRegister<UInt16>(41065, writable: true)] private UInt16 _requestedState; // 0xA069 – RequestedState (enum16, RW)
|
||||
[HoldingRegister<UInt16>(41066)] private UInt16 _currentState; // 0xA06A – CurrentState (enum16, R)
|
||||
[HoldingRegister<UInt16>(41067, writable: true)] private UInt16 _controlMode; // 0xA06B – ControlMode (enum16, RW)
|
||||
[HoldingRegister<UInt16>(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<UInt16>(41069, writable: true)] private UInt16 _watchdog; // 0xA06D – Watchdog (uint16, RW, seconds) 0 to 600
|
||||
[HoldingRegister<Int16> (41070, writable: true)] private Int16 _wSetPct; // 0xA06E – WSetPct (int16, RW, %WMax) unscaled: -100 to 100. Set power output to specified level.
|
||||
[HoldingRegister<Int16> (41071, writable: true)] private Int16 _varWMaxSetPct; // 0xA06F – VarWMaxSetPct (int16, RW, %SMax) unscaled: -100 to 100
|
||||
|
||||
// Reserved padding
|
||||
[HoldingRegister<UInt16>(41072)] private UInt16 _reserved11; // 0xA070
|
||||
[HoldingRegister<UInt16>(41073)] private UInt16 _reserved12; // 0xA071
|
||||
[HoldingRegister<UInt16>(41074)] private UInt16 _reserved13; // 0xA072
|
||||
[HoldingRegister<UInt16>(41075)] private UInt16 _reserved14; // 0xA073
|
||||
[HoldingRegister<UInt16>(41076)] private UInt16 _reserved15; // 0xA074
|
||||
[HoldingRegister<UInt16>(41077)] private UInt16 _reserved16; // 0xA075
|
||||
|
||||
// Status / error
|
||||
[HoldingRegister<UInt16>(41078)] private UInt16 _stVnd; // 0xA076 – StVnd (enum16, R) PrologState
|
||||
[HoldingRegister<UInt16>(41079)] private UInt16 _stPu; // 0xA077 – StPu (enum16, R) Power Unit State (DSP)
|
||||
[HoldingRegister<UInt16>(41080)] private UInt16 _stPcu; // 0xA078 – StPcu (enum16, R) pcu state
|
||||
[HoldingRegister<UInt16>(41081)] private UInt16 _errPcu; // 0xA079 – ErrPcu (enum16, R) pcu error
|
||||
|
||||
// Active power ramp parameters
|
||||
[HoldingRegister<UInt16>(41082, writable: true)] private UInt16 _wParamRmpTms; // 0xA07A – WParamRmpTms (uint16, RW, s)
|
||||
[HoldingRegister<UInt16>(41083, writable: true)] private UInt16 _wParamRmpDecTmn; // 0xA07B – WParamRmpDecTmn (uint16, RW, %ref/min)
|
||||
[HoldingRegister<UInt16>(41084, writable: true)] private UInt16 _wParamRmpIncTmn; // 0xA07C – WParamRmpIncTmn (uint16, RW, %ref/min)
|
||||
|
||||
[HoldingRegister<UInt16>(41085)] private UInt16 _reserved24; // 0xA07D – Reserved
|
||||
[HoldingRegister<UInt16>(41086)] private UInt16 _reserved25; // 0xA07E – Reserved
|
||||
|
||||
[HoldingRegister<UInt16>(41087, writable: true)] private UInt16 _wParamEna; // 0xA07F – WParam_Ena (enum16, RW) WSet_Ena control 0 or 1
|
||||
|
||||
// Reactive power ramp parameters
|
||||
[HoldingRegister<UInt16>(41088, writable: true)] private UInt16 _varParamRmpTms; // 0xA080 – VarParamRmpTms (uint16, RW, s)
|
||||
[HoldingRegister<UInt16>(41089, writable: true)] private UInt16 _varParamRmpDecTmn; // 0xA081 – VarParamRmpDecTmn (uint16, RW, %ref/min)
|
||||
[HoldingRegister<UInt16>(41090, writable: true)] private UInt16 _varParamRmpIncTmn; // 0xA082 – VarParamRmpIncTmn (uint16, RW, %ref/min)
|
||||
|
||||
[HoldingRegister<UInt16>(41091)] private UInt16 _reserved30; // 0xA083 – Reserved
|
||||
[HoldingRegister<UInt16>(41092)] private UInt16 _reserved31; // 0xA084 – Reserved
|
||||
|
||||
[HoldingRegister<UInt16>(41093, writable: true)] private UInt16 _varParamEna; // 0xA085 – VarParam_Ena (enum16, RW) Enumerated valued. Percent limit VAr enable/disable control.
|
||||
|
||||
// Measurements (read-only)
|
||||
[HoldingRegister<UInt16>(41094)] private UInt16 _phaseVoltageAN; // 0xA086 – PhVphA (uint16, R, V, V_SF)
|
||||
[HoldingRegister<UInt16>(41095)] private UInt16 _phaseVoltageBN; // 0xA087 – PhVphB (uint16, R, V, V_SF)
|
||||
[HoldingRegister<UInt16>(41096)] private UInt16 _phaseVoltageCN; // 0xA088 – PhVphC (uint16, R, V, V_SF)
|
||||
|
||||
[HoldingRegister<Int16> (41097)] private Int16 _activePowerW; // 0xA089 – W (int16, R, W, W_SF)
|
||||
[HoldingRegister<Int16> (41098)] private Int16 _reactivePowerVar; // 0xA08A – VAR (int16, R, var, Var_SF)
|
||||
[HoldingRegister<Int16> (41099)] private Int16 _lineFrequencyHz; // 0xA08B – Hz (int16, R, Hz, Hz_SF)
|
||||
|
||||
// Scale factors (SunSpec sunsf)
|
||||
// Scale factor for active power percent.
|
||||
[HoldingRegister<Int16>(41107)] private Int16 _wSetPctSf; // 0xA0F3 – WSetPct_SF
|
||||
// Scale factor for reactive power percent.
|
||||
[HoldingRegister<Int16>(41108)] private Int16 _varSetPctSf; // 0xA0F4 – VarSetPct_SF
|
||||
// Scale factor for PT1 (ramp time).
|
||||
[HoldingRegister<Int16>(41109)] private Int16 _rmpTmsSf; // 0xA0F5 – RmpTms_SF
|
||||
// Scale factor for increment and decrement ramps.
|
||||
[HoldingRegister<Int16>(41110)] private Int16 _rmpIncDecSf; // 0xA0F6 – RmpIncDec_SF
|
||||
|
||||
// Header
|
||||
[HoldingRegister<UInt16>(41115)] private UInt16 _battCharId; // ID = 64202
|
||||
[HoldingRegister<UInt16>(41116)] private UInt16 _battCharLength; // L = 6 + (RBCount * 8)
|
||||
|
||||
// Fixed block
|
||||
[HoldingRegister<UInt16>(41117)] private UInt16 _battCharVersion; // Version (uint16, R)
|
||||
[HoldingRegister<UInt16>(41118)] private UInt16 _battCharVerMinor; // VerMinor (uint16, R)
|
||||
[HoldingRegister<UInt16>(41119)] private UInt16 _battCharRsrvd1; // Rsrvd_1 (pad)
|
||||
[HoldingRegister<UInt16>(41120)] private UInt16 _battCharRsrvd2; // Rsrvd_2 (pad)
|
||||
[HoldingRegister<Int16> (41121)] private Int16 _battCharVSf; // V_SF (sunsf, R)
|
||||
[HoldingRegister<Int16> (41122)] private Int16 _battCharASf; // A_SF (sunsf, R)
|
||||
|
||||
// Repeating block #0 (you said there is only one block)
|
||||
[HoldingRegister<UInt16>(41123, writable: true)] private UInt16 _disMinVRaw; // DisMinV (uint16, V, V_SF, RW) min. discharge voltage
|
||||
[HoldingRegister<UInt16>(41124, writable: true)] private UInt16 _disMaxARaw; // DisMaxA (uint16, A, A_SF, RW)max. discharge current
|
||||
[HoldingRegister<UInt16>(41125, writable: true)] private UInt16 _disCutoffARaw; // DisCutoffA (uint16, A, A_SF, RW)Disconnect if discharge current lower than DisCutoffA
|
||||
[HoldingRegister<UInt16>(41126, writable: true)] private UInt16 _chaMaxVRaw; // ChaMaxV (uint16, V, V_SF, RW)max. charge voltage
|
||||
[HoldingRegister<UInt16>(41127, writable: true)] private UInt16 _chaMaxARaw; // ChaMaxA (uint16, A, A_SF, RW)max. charge current
|
||||
[HoldingRegister<UInt16>(41128, writable: true)] private UInt16 _chaCutoffARaw; // ChaCutoffA (uint16, A, A_SF, RW)Disconnect if charge current lower than ChaCuttoffA
|
||||
[HoldingRegister<UInt16>(41129)] private UInt16 _battCharPad; // Pad (pad, R)
|
||||
[HoldingRegister<UInt16>(41130, writable: true)] private UInt16 _enLimitRaw; // EnLimit (uint16, RW)new battery limits are activated when EnLimit is 1
|
||||
|
||||
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue