using System.IO.Compression; using InnovEnergy.Lib.Devices.WITGrowatt4_15K; using System.IO.Ports; using System.Text; using System.Text.Json; using System.Xml; using Flurl.Http; using InnovEnergy.App.GrowattCommunication.DataLogging; using InnovEnergy.App.GrowattCommunication.ESS; using InnovEnergy.App.GrowattCommunication.MiddlewareClasses; using InnovEnergy.App.GrowattCommunication.SystemConfig; 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; using System.Diagnostics.CodeAnalysis; using InnovEnergy.App.GrowattCommunication.DataTypes; using InnovEnergy.Lib.Devices.WITGrowatt4_15K.DataType; using static InnovEnergy.App.GrowattCommunication.MiddlewareClasses.MiddlewareAgent; namespace InnovEnergy.App.GrowattCommunication; public static class Program { private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(2); private const UInt16 NbrOfFileToConcatenate = 15; // add this to config file private static UInt16 _fileCounter = 0; // private static Channel _growattChannel; private const String SwVersionNumber =" V1.00.310725 beta"; private const String VpnServerIp = "10.2.0.11"; private static Boolean _subscribedToQueue = false; private 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; // move all this to config file private const String Port = "/dev/ttyUSB0"; private const Byte SlaveId = 1; private const Parity Parity = 0; //none private const Int32 StopBits = 1; private const Int32 BaudRate = 9600; private const Int32 DataBits = 8; [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] public static async Task Main(String[] args) { _growattChannel = new SerialPortChannel(Port, BaudRate, Parity, DataBits, StopBits); InitializeCommunicationToMiddleware(); while (true) { try { await Run(); } catch (Exception e) { e.LogError(); } } } private static async Task Run() { Watchdog.NotifyReady(); Console.WriteLine("Starting Growatt Communication"); var growattDeviceT415K = new WITGrowatDevice(_growattChannel, SlaveId); // var growattDevices = new WITGrowattDevices(new List { growattDeviceT415K }); StatusRecord ReadStatus() { var config = Config.Load(); var growattRecord = growattDeviceT415K.Read(); return new StatusRecord { AcDcGrowatt = growattRecord, Config = config // load from disk every iteration, so config can be changed while running }; } while (true) { try { Watchdog.NotifyAlive(); var timestamp = DateTime.Now.Round(UpdateInterval).ToUnixTime(); $"{timestamp} : {DateTime.Now.Round(UpdateInterval):dd/MM/yyyy HH:mm:ss}".WriteLine(); var startTime = DateTime.Now; Console.WriteLine("***************************** Reading Battery Data *********************************************"); Console.WriteLine(startTime.ToString("HH:mm:ss.fff")); // the order matter of the next three lines var statusrecord = ReadStatus(); SendSalimaxStateAlarm(GetSodiHomeStateAlarm(statusrecord),statusrecord); await DataLogging(statusrecord, timestamp); // save a csv file locally await SaveModbusTcpFile(statusrecord); // save the json file for modbuscTCP EssModeControl(statusrecord,EssMode.LoadPriority); // this should moved to config file statusrecord.ApplyDefaultSettings(); statusrecord.Config.Save(); // save the config file Console.WriteLine( " ************************************ We are writing ************************************"); growattDeviceT415K.Write(statusrecord.AcDcGrowatt); // Wait for 2 seconds before the next reading // await Task.Delay(1000); // Delay in milliseconds (1000ms = 1 seconds) await Task.Delay(2000); // Delay in milliseconds (1000ms = 1 seconds) } catch (Exception e) { // Handle exception and print the error Console.WriteLine(e ); // await Task.Delay(2000); // Delay in milliseconds (2000ms = 2 seconds) } } } private static void ApplyConfigFile(this StatusRecord status, Configuration? config) { if (config == null) return; status.Config.MinSoc = config.MinimumSoC; status.Config.GridSetPoint = config.GridSetPoint * 1000; // converted from kW to W status.Config.MaximumChargingCurrent = config.MaximumChargingCurrent; status.Config.MaximumDischargingCurrent = config.MaximumDischargingCurrent; // converted from kW to W } private static String EssModeControl(StatusRecord statusrecord, EssMode mode) { switch (mode) { case EssMode.Off: return "no mode"; case EssMode.GridPriority: statusrecord.AcDcGrowatt.RemotePowerControl = true; statusrecord.AcDcGrowatt.RemotePowerControlChargeDuration = 0; // 30408 this the duration //statusrecord.AcDcGrowatt.ActivePowerPercent = 50; // 30408 this the duration //statusrecord.AcDcGrowatt.ActivePowerPercentDerating = 50; // 30408 this the duration statusrecord.AcDcGrowatt.RemoteChargDischargePower = - 100; //30409 we set power here // for grid priority from 0 to -100 statusrecord.AcDcGrowatt.ActualChargeDischargePowerControlValue.WriteLine(" register 30474");; // this to check what was set return "Grid priority mode active"; case EssMode.BatteryPriority: statusrecord.AcDcGrowatt.RemotePowerControl = true; statusrecord.AcDcGrowatt.RemotePowerControlChargeDuration = 0; // 30408 this the duration statusrecord.AcDcGrowatt.RemoteChargDischargePower = 100; //30409 we set power here // for grid priority from 0 to 100 statusrecord.AcDcGrowatt.ActualChargeDischargePowerControlValue.WriteLine(" register 30474");; // this to check what was set return "Battery priority mode active"; case EssMode.LoadPriority: statusrecord.AcDcGrowatt.RemotePowerControl = false; return "Load priority mode active"; default: throw new ArgumentOutOfRangeException(nameof(mode), mode, null); } } private static StatusMessage GetSodiHomeStateAlarm(StatusRecord record) { var s3Bucket = Config.Load().S3?.Bucket; var alarmList = new List(); var warningList = new List(); if (record.AcDcGrowatt.SystemOperatingMode == GrowattSystemStatus.Fault) { if (record.AcDcGrowatt.FaultMainCode != 0) { alarmList.Add(new AlarmOrWarning { Date = DateTime.Now.ToString("yyyy-MM-dd"), Time = DateTime.Now.ToString("HH:mm:ss"), CreatedBy = "Growatt Inverter", Description = record.AcDcGrowatt.WarningMainCode.ToString(), // to add the sub code }); } if (record.AcDcGrowatt.WarningMainCode != 0) { warningList.Add(new AlarmOrWarning { Date = DateTime.Now.ToString("yyyy-MM-dd"), Time = DateTime.Now.ToString("HH:mm:ss"), CreatedBy = "Growatt inverter", Description = record.AcDcGrowatt.FaultMainCode.ToString(), //to add the sub code }); } } _sodiohomeAlarmState = warningList.Any() ? SodistoreAlarmState.Orange : SodistoreAlarmState.Green; // this will be replaced by LedState _sodiohomeAlarmState = alarmList.Any() ? SodistoreAlarmState.Red : _sodiohomeAlarmState; // this will be replaced by LedState var installationId = GetInstallationId(s3Bucket ?? string.Empty); var returnedStatus = new StatusMessage { InstallationId = installationId, Product = 2, Status = _sodiohomeAlarmState, Type = MessageType.AlarmOrWarning, Alarms = alarmList, Warnings = warningList }; return returnedStatus; } private static Int32 GetInstallationId(String s3Bucket) { var part = s3Bucket.Split('-').FirstOrDefault(); return int.TryParse(part, out var id) ? id : 0; // is 0 a default safe value? check with Marios } private static void SendSalimaxStateAlarm(StatusMessage currentSalimaxState, StatusRecord record) { var s3Bucket = Config.Load().S3?.Bucket; var subscribedNow = false; //When the controller boots, it tries to subscribe to the queue if (_subscribeToQueueForTheFirstTime == false) { subscribedNow = true; _subscribeToQueueForTheFirstTime = true; _prevSodiohomeAlarmState = currentSalimaxState.Status; _subscribedToQueue = RabbitMqManager.SubscribeToQueue(currentSalimaxState, s3Bucket, VpnServerIp); } //If already subscribed to the queue and the status has been changed, update the queue if (!subscribedNow && _subscribedToQueue && currentSalimaxState.Status != _prevSodiohomeAlarmState) { _prevSodiohomeAlarmState = currentSalimaxState.Status; if (s3Bucket != null) RabbitMqManager.InformMiddleware(currentSalimaxState); } //If there is an available message from the RabbitMQ Broker, apply the configuration file Configuration? config = SetConfigurationFile(); if (config != null) { record.ApplyConfigFile(config); } } private static void ApplyDefaultSettings(this StatusRecord? st) { if (st is null) return; st.AcDcGrowatt.BatteryMaxChargeCurrent = st.Config.MaximumChargingCurrent; st.AcDcGrowatt.BatteryMaxdischargeCurrent = st.Config.MaximumDischargingCurrent; st.AcDcGrowatt.EmsCommunicationFailureTime = 20; // 20 sec st.AcDcGrowatt.EnableEmsCommunicationFailureTime = false; st.AcDcGrowatt.EnableCommand = true; st.AcDcGrowatt.ControlPermession = true; st.AcDcGrowatt.BatteryChargeCutoffVoltage = 100; //st.Config.BatteryChargeCutoffVoltage; st.AcDcGrowatt.BatteryDischargeCutoffVoltage = 20; //st.Config.BatteryDischargeCutoffVoltage; st.AcDcGrowatt.BatteryMaxChargeCurrent = 200; //st.Config.BatteryChargeCutoffVoltage; st.AcDcGrowatt.BatteryMaxdischargeCurrent = 200; //st.Config.BatteryChargeCutoffVoltage; } 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; } [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize>(System.Collections.Generic.Dictionary, System.Text.Json.JsonSerializerOptions?)")] private static async Task SaveModbusTcpFile(StatusRecord status) { var modbusData = new Dictionary(); const Double protcolNumber = 1.1; var pv1Power = status.AcDcGrowatt.Pv1Current * status.AcDcGrowatt.Pv1Voltage; var pv2Power = status.AcDcGrowatt.Pv2Current * status.AcDcGrowatt.Pv2Voltage; // SYSTEM DATA var result1 = ConvertToModbusRegisters((protcolNumber * 10), "UInt16", 30000); // this to be updated to modbusTCP version var result2 = ConvertToModbusRegisters(status.AcDcGrowatt.SystemDateTime.ToUnixTime(), "UInt32", 30001); var result3 = ConvertToModbusRegisters(status.AcDcGrowatt.SystemOperatingMode, "UInt16", 30003); var result17 = ConvertToModbusRegisters(status.AcDcGrowatt.OperatingPriority, "UInt16", 30004); // BATTERY SUMMARY (assuming single battery [0]) // this to be improved var battery = status.AcDcGrowatt.BatteriesRecords!.Batteries[0]; var result4 = ConvertToModbusRegisters((status.AcDcGrowatt.BatteriesRecords!.Batteries.Count ), "UInt16", 30099); var result5 = ConvertToModbusRegisters((battery.Power.Value * 10), "Int32", 31001); var result6 = ConvertToModbusRegisters((battery.DailyChargeEnergy.Value * 10), "UInt32", 31003); var result7 = ConvertToModbusRegisters((battery.AccumulatedChargeEnergy.Value * 10), "UInt32", 31005); var result8 = ConvertToModbusRegisters((battery.DailyDischargeEnergy.Value * 10), "UInt32", 31007); var result9 = ConvertToModbusRegisters((battery.AccumulatedDischargeEnergy.Value * 10), "UInt32", 31009); var result10 = ConvertToModbusRegisters((battery.MaxAllowableDischargePower.Value * 10), "UInt32", 31011); var result11 = ConvertToModbusRegisters((battery.MaxAllowableDischargePower.Value * 10), "UInt32", 31013); var result12 = ConvertToModbusRegisters((battery.Voltage.Value * 10), "Int16", 31015); var result13 = ConvertToModbusRegisters((battery.Current.Value * 10), "Int32", 31016); var result14 = ConvertToModbusRegisters((battery.Soc.Value * 100), "UInt16", 31018); var result15 = ConvertToModbusRegisters((status.AcDcGrowatt.BatteriesRecords!.AverageSoh * 100), "UInt16", 31020); var result16 = ConvertToModbusRegisters((battery.BatteryAmbientTemperature.Value * 100), "UInt16", 31021); var result18 = ConvertToModbusRegisters(((pv1Power + pv2Power) * 10), "UInt32", 31999); var result19 = ConvertToModbusRegisters((status.AcDcGrowatt.GridMeterPower.Value * 10), "Int32", 32999); // Merge all results into one dictionary var allResults = new[] { result1,result2, result3, result17, result4, result5, result6, result7, result8, result9, result10, result11, result12, result13, result14, result15, result16, result18, result19 }; 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); } private static async Task DataLogging(StatusRecord status, Int64 timestamp) { var csv = status.ToCsv(); // for debug, only to be deleted. //foreach (var item in csv.SplitLines()) //{ // Console.WriteLine(item + ""); //} await SavingLocalCsvFile(timestamp, 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, 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 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]; } } } private static async Task SavingLocalCsvFile(Int64 timestamp, String csv) { const String directoryPath = "/home/inesco/SodiStoreHome/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 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 = 2, Status = _sodiohomeAlarmState, 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 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 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 byte[] ComputeCrc16Appended(byte[] data) { ushort crc = 0xFFFF; foreach (byte b in data) { crc ^= b; for (int i = 0; i < 8; i++) { bool lsb = (crc & 0x0001) != 0; crc >>= 1; if (lsb) { crc ^= 0xA001; } } } byte crcLow = (byte)(crc & 0xFF); byte crcHigh = (byte)((crc >> 8) & 0xFF); // Create a new array with space for CRC byte[] result = new byte[data.Length + 2]; Array.Copy(data, result, data.Length); result[result.Length - 2] = crcLow; result[result.Length - 1] = crcHigh; return result; } }