// See https://aka.ms/new-console-template for more information using System.IO.Compression; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; using System.Text; using System.Text.Json; using Flurl.Http; using InnovEnergy.App.KacoCommunication.DataLogging; using InnovEnergy.App.KacoCommunication.DataTypes; using InnovEnergy.App.KacoCommunication.Devices; using InnovEnergy.App.KacoCommunication.ESS; using InnovEnergy.App.KacoCommunication.MiddlewareClasses; using InnovEnergy.App.KacoCommunication.SystemConfig; using InnovEnergy.Lib.Devices.BatteryDeligreen; using InnovEnergy.Lib.Devices.Kaco92L3; using InnovEnergy.Lib.Devices.Kaco92L3.DataType; using InnovEnergy.Lib.Devices.PLVario2Meter; using InnovEnergy.Lib.Devices.Trumpf.TruConvertDc; using InnovEnergy.Lib.Protocols.Modbus.Channels; using InnovEnergy.Lib.Units; using InnovEnergy.Lib.Utils; using Newtonsoft.Json; using Formatting = Newtonsoft.Json.Formatting; using JsonSerializer = System.Text.Json.JsonSerializer; namespace InnovEnergy.App.KacoCommunication; internal static class Program { private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(5); private const UInt16 NbrOfFileToConcatenate = 15; // add this to config file private static UInt16 _fileCounter = 0; private static SodistoreAlarmState _prevSodiohomeAlarmState = SodistoreAlarmState.Green; private static SodistoreAlarmState _sodiAlarmState = SodistoreAlarmState.Green; private static readonly IReadOnlyList BatteryNodes; private static readonly Channel KacoChannel; private static readonly Channel GridMeterChannel; private static readonly Channel DcDcChannel; private const String Port1Cabinet = "/dev/ttyUSB0"; // move to a config file private const String Port2Cabinet = "/dev/ttyUSB1"; // move to a config file private const String Port3Cabinet = "/dev/ttyUSB2"; // move to a config file private static readonly String SwVersionNumber = " V1.00." + DateTime.Today; private const String VpnServerIp = "10.2.0.11"; public static Boolean _subscribedToQueue = false; public static Boolean _subscribeToQueueForTheFirstTime = false; private static Int32 _failsCounter = 0; // move to a config file // private static SodistoreAlarmState _prevSodiohomeAlarmState = SodistoreAlarmState.Green; // private static SodistoreAlarmState _sodiohomeAlarmState = SodistoreAlarmState.Green; static Program() { var config = Config.Load(); var d = config.Devices; Channel CreateChannel(SalimaxDevice device) => device.DeviceState == DeviceState.Disabled ? new NullChannel() : new TcpChannel(device); BatteryNodes = config .Devices .BatteryNodes .Select(n => n.ConvertTo()) .ToArray(config.Devices.BatteryNodes.Length); KacoChannel = CreateChannel(d.KacoIp); GridMeterChannel = CreateChannel(d.GridMeterIp); DcDcChannel = CreateChannel(d.DcDcIp); } public static async Task Main(String[] args) { while (true) { try { await Run(); } catch (Exception e) { // e.LogError(); } } // ReSharper disable once FunctionNeverReturns } private static async Task Run() { Watchdog.NotifyReady(); Console.WriteLine("Starting Kaco Communication"); var kacoDevice = new KacoDevice(KacoChannel); var gridMeterDevice = new PlVarioMeterDevice(GridMeterChannel); var dcDcDevices = new TruConvertDcDcDevices(DcDcChannel); var firstCabinetBatteriesDevice = BatteryNodes.Select(n => new BatteryDeligreenDevice(Port1Cabinet, n)).ToList(); var secondCabinetBatteriesDevice = BatteryNodes.Select(n => new BatteryDeligreenDevice(Port2Cabinet, n)).ToList(); var thirdCabinetBatteriesDevice = BatteryNodes.Select(n => new BatteryDeligreenDevice(Port3Cabinet, n)).ToList(); var batteryDevices1 = new BatteryDeligreenDevices(firstCabinetBatteriesDevice); var batteryDevices2 = new BatteryDeligreenDevices(secondCabinetBatteriesDevice); var batteryDevices3 = new BatteryDeligreenDevices(thirdCabinetBatteriesDevice); StatusRecord? ReadStatus() { PlVarioMeterRecord? gridRecord = null; var config = Config.Load(); var kacoRecord = kacoDevice.Read(); var gridrawRecord = gridMeterDevice.Read(); var dcDcRecord = dcDcDevices.Read(); if (gridrawRecord != null) { gridRecord = new PlVarioMeterRecord(gridrawRecord); } var batteryKabinet1 = batteryDevices1.Read(); var batteryKabinet2 = batteryDevices2.Read(); var batteryKabinet3 = batteryDevices3.Read(); return new StatusRecord { InverterRecord = kacoRecord, GridMeterRecord = gridRecord, DcDc = dcDcRecord, BatteryKabinet1 = batteryKabinet1, BatteryKabinet2 = batteryKabinet2, BatteryKabinet3 = batteryKabinet3, Config = config // load from disk every iteration, so config can be changed while running }; } while (true) { await Observable .Interval(UpdateInterval) .Select(_ => RunIteration()) .SelectMany(status => DataLogging(status, DateTime.Now.Round(UpdateInterval)) .ContinueWith(_ => status)) // back to StatusRecord .SelectMany(SaveModbusTcpFile) .SelectError() .ToTask(); } StatusRecord? RunIteration() { try { Watchdog.NotifyAlive(); var startTime = DateTime.Now; Console.WriteLine( "***************************** Reading Kaco Data *********************************************"); Console.WriteLine(startTime.ToString("HH:mm:ss.fff")); // the order matter of the next three lines var statusrecord = ReadStatus(); statusrecord?.CreateSimpleTopologyTextBlock().WriteLine(); // statusrecord?.DcDc?.Dc.Battery.Power .WriteLine(" Power"); // statusrecord?.DcDc?.Dc.Battery.Voltage .WriteLine(" Voltage"); // statusrecord?.DcDc?.Dc.Battery.Current .WriteLine(" Current"); // statusrecord?.DcDc?.Dc.Link.Voltage .WriteLine(" Dc link Voltage"); statusrecord?.GridMeterRecord?.Frequency .WriteLine(" Frequency"); statusrecord?.GridMeterRecord?.VoltageU1 .WriteLine(" VoltageU1"); statusrecord?.GridMeterRecord?.VoltageU2 .WriteLine(" VoltageU2"); statusrecord?.GridMeterRecord?.VoltageU3 .WriteLine(" VoltageU3"); statusrecord?.GridMeterRecord?.CurrentI1 .WriteLine(" CurrentI1"); statusrecord?.GridMeterRecord?.CurrentI2 .WriteLine(" CurrentI2"); statusrecord?.GridMeterRecord?.CurrentI3 .WriteLine(" CurrentI3"); statusrecord?.GridMeterRecord?.ActivePowerL1 .WriteLine(" ActivePowerL1"); statusrecord?.GridMeterRecord?.ActivePowerL2 .WriteLine(" ActivePowerL2"); statusrecord?.GridMeterRecord?.ActivePowerL3 .WriteLine(" ActivePowerL3"); statusrecord?.GridMeterRecord?.ActivePowerTotal .WriteLine(" ActivePowerTotal"); statusrecord?.InverterRecord?.CurrentState.WriteLine(" CurrentState"); statusrecord?.InverterRecord?.RequestedState.WriteLine(" RequestedState"); statusrecord?.InverterRecord?.PcuError.WriteLine(" PcuError"); statusrecord?.InverterRecord?.PcuState.WriteLine(" PcuState"); statusrecord?.InverterRecord?.BattCharId.WriteLine(" _battCharId"); statusrecord?.InverterRecord?.BattCharLength.WriteLine(" _battCharLength"); statusrecord?.InverterRecord?.MinDischargeVoltage.WriteLine(" MinDischargeVoltage"); statusrecord?.InverterRecord?.MaxDischargeCurrent.WriteLine(" MaxDischargeCurrent"); statusrecord?.InverterRecord?.DischargeCutoffCurrent.WriteLine(" DischargeCutoffCurrent"); statusrecord?.InverterRecord?.MaxChargeVoltage.WriteLine(" MaxChargeVoltage"); statusrecord?.InverterRecord?.MaxChargeCurrent.WriteLine(" MaxChargeCurrent"); statusrecord?.InverterRecord?.ChargeCutoffCurrent.WriteLine(" ChargeCutoffCurrent"); statusrecord?.InverterRecord?.ActivePowerSetPercent.WriteLine(" ActivePowerSetPercent"); statusrecord?.InverterRecord?.ReactivePowerSetPercent.WriteLine(" ReactivePowerSetPercent"); statusrecord?.InverterRecord?.WatchdogSeconds.WriteLine(" WatchdogSeconds"); InitializeKacoStartup(statusrecord); Console.WriteLine( " ************************************ We are writing ************************************"); statusrecord?.Config.Save(); // save the config file if (statusrecord?.InverterRecord != null) kacoDevice.Write(statusrecord.InverterRecord); Console.WriteLine(DateTime.Now.ToString("HH:mm:ss.fff")); return statusrecord; } catch (Exception e) { // Handle exception and print the error Console.WriteLine(e); return null; } } } private static async Task SavingLocalCsvFile(Int64 timestamp, String csv) { const String directoryPath = "/home/inesco/salimax/csvFile"; // Ensure directory exists if (!Directory.Exists(directoryPath)) { Directory.CreateDirectory(directoryPath); } // Get all .csv files ordered by creation time (oldest first) var csvFiles = new DirectoryInfo(directoryPath) .GetFiles("*.csv") .OrderBy(f => f.CreationTimeUtc) .ToList(); // If more than 5000 files, delete the oldest if (csvFiles.Count >= 5000) { var oldestFile = csvFiles.First(); try { oldestFile.Delete(); } catch (Exception ex) { Console.WriteLine($"Failed to delete file: {oldestFile.FullName}, Error: {ex.Message}"); } } // Prepare the filtered CSV content var filteredCsv = csv .SplitLines() .Where(l => !l.Contains("Secret")) .JoinLines(); // Save the new CSV file var filePath = Path.Combine(directoryPath, timestamp + ".csv"); await File.WriteAllTextAsync(filePath, filteredCsv); } private static async Task DataLogging(StatusRecord status, DateTime timeStamp) { var csv = status.ToCsv(); // for debug, only to be deleted. //foreach (var item in csv.SplitLines()) //{ // Console.WriteLine(item + ""); //} await SavingLocalCsvFile(timeStamp.ToUnixTime(), csv); var jsonData = new Dictionary(); ConvertToJson(csv, jsonData).LogInfo(); var s3Config = status.Config.S3; if (s3Config is null) return false; //Concatenating 15 files in one file return await ConcatinatingAndCompressingFiles(timeStamp.ToUnixTime(), s3Config); } private static String ConvertToJson(String csv, Dictionary jsonData) { foreach (var line in csv.Split('\n')) { if (string.IsNullOrWhiteSpace(line)) continue; var parts = line.Split(';'); var keyPath = parts[0]; var value = parts[1]; var unit = parts.Length > 2 ? parts[2].Trim() : ""; InsertIntoJson(jsonData, keyPath.Split('/'), value); } var jsonOutput = JsonConvert.SerializeObject(jsonData, Formatting.None); return jsonOutput; } private static async Task ConcatinatingAndCompressingFiles(Int64 timeStamp, S3Config s3Config) { if (_fileCounter >= NbrOfFileToConcatenate) { _fileCounter = 0; var logFileConcatenator = new LogFileConcatenator(); var jsontoSend = logFileConcatenator.ConcatenateFiles(NbrOfFileToConcatenate); var fileNameWithoutExtension = timeStamp.ToString(); // used for both S3 and local var s3Path = fileNameWithoutExtension + ".json"; var request = s3Config.CreatePutRequest(s3Path); var compressedBytes = CompresseBytes(jsontoSend); var base64String = Convert.ToBase64String(compressedBytes); var stringContent = new StringContent(base64String, Encoding.UTF8, "application/base64"); var uploadSucceeded = false; try { var response = await request.PutAsync(stringContent); if (response.StatusCode != 200) { Console.WriteLine("ERROR: PUT"); var error = await response.GetStringAsync(); Console.WriteLine(error); await SaveToLocalCompressedFallback(compressedBytes, fileNameWithoutExtension); Heartbit(); return false; } uploadSucceeded = true; Console.WriteLine("✅ File uploaded to S3 successfully."); Console.WriteLine( "---------------------------------------- Resending FailedUploadedFiles----------------------------------------"); Heartbit(); await ResendLocalFailedFilesAsync(s3Config); // retry any pending failed files } catch (Exception ex) { Console.WriteLine("Upload exception: " + ex.Message); if (!uploadSucceeded) { await SaveToLocalCompressedFallback(compressedBytes, fileNameWithoutExtension); } Heartbit(); return false; } } _fileCounter++; return true; } private static void Heartbit() { var s3Bucket = Config.Load().S3?.Bucket; var tryParse = int.TryParse(s3Bucket?.Split("-")[0], out var installationId); if (tryParse) { var returnedStatus = new StatusMessage { InstallationId = installationId, Product = 3, Status = _sodiAlarmState, Type = MessageType.Heartbit, }; if (s3Bucket != null) RabbitMqManager.InformMiddleware(returnedStatus); } } private static async Task SaveToLocalCompressedFallback(Byte[] compressedData, String fileNameWithoutExtension) { try { var fallbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FailedUploads"); Directory.CreateDirectory(fallbackDir); var fileName = fileNameWithoutExtension + ".json"; // Save as .json, but still compressed var fullPath = Path.Combine(fallbackDir, fileName); await File.WriteAllBytesAsync(fullPath, compressedData); // Compressed data Console.WriteLine($"Saved compressed failed upload to: {fullPath}"); } catch (Exception ex) { Console.WriteLine("Failed to save compressed file locally: " + ex.Message); } } private static Byte[] CompresseBytes(String jsonToSend) { //Compress JSON data to a byte array using var memoryStream = new MemoryStream(); //Create a zip directory and put the compressed file inside using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) { var entry = archive.CreateEntry("data.json", CompressionLevel.SmallestSize); // Add JSON data to the ZIP archive using (var entryStream = entry.Open()) using (var writer = new StreamWriter(entryStream)) { writer.Write(jsonToSend); } } var compressedBytes = memoryStream.ToArray(); return compressedBytes; } private static async Task ResendLocalFailedFilesAsync(S3Config s3Config) { var fallbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FailedUploads"); if (!Directory.Exists(fallbackDir)) return; var files = Directory.GetFiles(fallbackDir, "*.json"); files.Length.WriteLine(" Number of failed files, to upload"); foreach (var filePath in files) { var fileName = Path.GetFileName(filePath); // e.g., "1720023600.json" try { byte[] compressedBytes = await File.ReadAllBytesAsync(filePath); var base64String = Convert.ToBase64String(compressedBytes); var stringContent = new StringContent(base64String, Encoding.UTF8, "application/base64"); var request = s3Config.CreatePutRequest(fileName); var response = await request.PutAsync(stringContent); if (response.StatusCode == 200) { File.Delete(filePath); Console.WriteLine($"✅ Successfully resent and deleted: {fileName}"); } else { Console.WriteLine($"❌ Failed to resend {fileName}, status: {response.StatusCode}"); } } catch (Exception ex) { Console.WriteLine($"⚠️ Exception while resending {fileName}: {ex.Message}"); } } } private static async Task SaveModbusTcpFile(StatusRecord status) { var modbusData = new Dictionary(); // SYSTEM DATA var result1 = ConvertToModbusRegisters((status.Config.MinSoc * 10), "UInt16", 30001); // this to be updated to modbusTCP version var result2 = ConvertToModbusRegisters(status.InverterRecord!.PcuError, "UInt32", 30002); // Merge all results into one dictionary var allResults = new[] { result1,result2 }; foreach (var result in allResults) { foreach (var entry in result) { modbusData[entry.Key] = entry.Value; } } // Write to JSON var json = JsonSerializer.Serialize(modbusData, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync("/home/inesco/SodiStoreHome/ModbusTCP/modbus_tcp_data.json", json); //Console.WriteLine("JSON file written successfully."); //Console.WriteLine(json); var stopTime = DateTime.Now; Console.WriteLine(stopTime.ToString("HH:mm:ss.fff" )+ " Finish the loop"); return true; } private static Dictionary ConvertToModbusRegisters(Object value, String outputType, Int32 startingAddress) { var registers = new Dictionary(); switch (outputType) { case "UInt16": registers[startingAddress.ToString()] = Convert.ToUInt16(value); break; case "Int16": var int16Val = Convert.ToInt16(value); registers[startingAddress.ToString()] = (UInt16)int16Val; // reinterpret signed as ushort break; case "UInt32": var uint32Val = Convert.ToUInt32(value); registers[startingAddress.ToString()] = (UInt16)(uint32Val & 0xFFFF); // Low word registers[(startingAddress + 1).ToString()] = (UInt16)(uint32Val >> 16); // High word break; case "Int32": var int32Val = Convert.ToInt32(value); var raw = unchecked((UInt32)int32Val); // reinterprets signed int as unsigned registers[startingAddress.ToString()] = (UInt16)(raw & 0xFFFF); registers[(startingAddress + 1).ToString()] = (UInt16)(raw >> 16); break; default: throw new ArgumentException("Unsupported output type: " + outputType); } return registers; } private static void InitializeKacoStartup(StatusRecord? statusRecord) { // // 1. Apply DC – This part is physical and cannot be done in software. // We assume DC power is already present. // // // 2. Send valid battery limits (Model 64202) // All values temporarily set to "1" as requested. // You will replace them later with real values. // if (statusRecord?.InverterRecord != null) { statusRecord.InverterRecord.MinDischargeVoltage = 700f; // 64202.DisMinV statusRecord.InverterRecord.MaxDischargeCurrent = 140f; // 64202.DisMaxA statusRecord.InverterRecord.DischargeCutoffCurrent = 10f; // 64202.DisCutoffA statusRecord.InverterRecord.MaxChargeVoltage = 800f; // 64202.ChaMaxV statusRecord.InverterRecord.MaxChargeCurrent = 140f; // 64202.ChaMaxA statusRecord.InverterRecord.ChargeCutoffCurrent = 10f; // 64202.ChaCutoffA statusRecord.InverterRecord.WatchdogSeconds = 30; // this is additional from my seid // // 3. Enable limits (EnLimit) // statusRecord.InverterRecord.BatteryLimitsEnable = EnableDisableEnum.Enabled; // // After writing all values in software, send them to the inverter // // // 4. Read model 64201 to observe CurrentState transition // // Expected sequence: // - Before valid limits: CurrentState == 7 (ERROR) // - After valid limits: CurrentState == 8 (STANDBY) // - Then after grid/DC conditions: CurrentState == 1 (OFF) or 11 (GRID_CONNECTED) // var state = statusRecord.InverterRecord.CurrentState; Console.WriteLine($"KACO 64201.CurrentState = {state}"); switch (state) { case CurrentState.Standby: Console.WriteLine("Device is in STANDBY (8) — battery limits accepted."); break; case CurrentState.Off: Console.WriteLine("Device is OFF (1) — OK for non-battery operation."); break; case CurrentState.GridConnected: Console.WriteLine("Device is GRID CONNECTED (11)."); break; default: Console.WriteLine("Device in unexpected state: " + state); break; } //Thread.Sleep(2000); } } private static void InsertIntoJson(Dictionary jsonDict, String[] keys, String value) { var currentDict = jsonDict; for (Int16 i = 1; i < keys.Length; i++) // Start at 1 to skip empty root { var key = keys[i]; if (!currentDict.ContainsKey(key)) { currentDict[key] = new Dictionary(); } if (i == keys.Length - 1) // Last key, store the value { if (!value.Contains(",") && double.TryParse(value, out Double doubleValue)) // Try to parse value as a number { currentDict[key] = Math.Round(doubleValue, 2); // Round to 2 decimal places } else { currentDict[key] = value; // Store as string if not a number } } else { currentDict = (Dictionary)currentDict[key]; } } } }