Innovenergy_trunk/csharp/App/KacoCommunication/Program.cs

694 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.System;
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.SystemControl;
using InnovEnergy.Lib.Devices.Trumpf.SystemControl.DataTypes;
using InnovEnergy.Lib.Devices.Trumpf.TruConvertDc;
using InnovEnergy.Lib.Devices.Trumpf.TruConvertDc.Control;
using InnovEnergy.Lib.Protocols.Modbus.Channels;
using InnovEnergy.Lib.Units;
using InnovEnergy.Lib.Utils;
using Newtonsoft.Json;
using DeviceState = InnovEnergy.App.KacoCommunication.Devices.DeviceState;
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();
var listOfBatteriesRecord = new List<BatteryDeligreenRecords>();
if (batteryKabinet1 != null) listOfBatteriesRecord.Add(batteryKabinet1);
if (batteryKabinet2 != null) listOfBatteriesRecord.Add(batteryKabinet2);
if (batteryKabinet3 != null) listOfBatteriesRecord.Add(batteryKabinet3);
return new StatusRecord
{
InverterRecord = kacoRecord,
GridMeterRecord = gridRecord,
DcDc = dcDcRecord,
ListOfBatteriesRecord = listOfBatteriesRecord,
StateMachine = StateMachine.Default,
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?.StateMachine.State.WriteLine(" state");
statusrecord?.StateMachine.Message.WriteLine(" Message");
statusrecord?.InverterRecord?.BatteryLimitsEnable.WriteLine(" BatteryLimitsEnable");
Console.WriteLine(" **************** DcDc **********************");
statusrecord?.DcDc?.Dc.Battery.Power .WriteLine(" DC Battery Power");
statusrecord?.DcDc?.Dc.Battery.Voltage .WriteLine("DC Battery Voltage");
statusrecord?.DcDc?.Dc.Battery.Current .WriteLine("DC Battery Current");
statusrecord?.DcDc?.Dc.Link.Voltage .WriteLine(" Dc link Voltage");
Console.WriteLine(" ********************************* Kaco Inverter *********************************");
statusrecord?.InverterRecord?.ActivePowerW.WriteLine(" Inverter Power");
statusrecord?.InverterRecord?.CurrentState.WriteLine(" CurrentState");
statusrecord?.InverterRecord?.RequestedState.WriteLine(" RequestedState");
statusrecord?.InverterRecord?.PcuError.WriteLine(" PcuError");
statusrecord?.InverterRecord?.PcuState.WriteLine(" PcuState");
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?.ControlSystemState();
var i = 0;
foreach (var d in statusrecord.DcDc.Devices)
{
i++;
Console.WriteLine("before DcDc is " + i + d.Control.PowerStageEnable);
d.Control.ResetAlarmsAndWarnings = true;
d.Control.PowerStageEnable = true;
}
statusrecord?.DcDc?.SystemControl.ApplyDcDcDefaultSettings();
InitializeKacoStartup(statusrecord);
foreach (var d in statusrecord.DcDc.Devices)
{
Console.WriteLine("After DcDc is " + d.Control.PowerStageEnable);
}
Console.WriteLine(" ************************************ We are writing ************************************");
statusrecord?.Config.Save(); // save the config file
if (statusrecord?.InverterRecord != null) kacoDevice.Write(statusrecord.InverterRecord);
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)
{
var directoryPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "csvFiles");
// 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 ForAll<T>(this IEnumerable<T> ts, Action<T> action)
{
foreach (var t in ts)
action(t);
}
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.
//
Console.WriteLine("1. Apply DC");
//
statusRecord?.DcDc?.Devices
.Select(d => d.Control)
.ForAll(c => c.PowerStageEnable = true);
statusRecord?.DcDc?.Devices
.Select(d => d.Control )
.ForAll(c => c.ControlMode = DcControlMode.VoltageDroop);
//
// // 2. Send valid battery limits (Model 64202)
// All values temporarily set to "1" as requested.
// You will replace them later with real values.
//
Console.WriteLine("2. Send real value");
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)
Console.WriteLine("3. Enable limits ");
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)
//
Console.WriteLine("3. Read current state");
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;
}
}
}
private static void ApplyDcDcDefaultSettings(this SystemControlRegisters? sc)
{
if (sc is null)
return;
sc.SystemConfig = Lib.Devices.Trumpf.SystemControl.DataTypes.SystemConfig.DcDcOnly;
sc.CommunicationTimeout = TimeSpan.FromSeconds(20);
sc.PowerSetPointActivation = PowerSetPointActivation.Immediate;
sc.UseSlaveIdForAddressing = true;
sc.SlaveErrorHandling = SlaveErrorHandling.Relaxed;
sc.SubSlaveErrorHandling = SubSlaveErrorHandling.Off;
sc.TargetSlave = 0;
sc.ResetAlarmsAndWarnings = true;
}
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];
}
}
}
}