using System.Diagnostics; using System.IO.Compression; using System.IO.Ports; using System.Text; using System.Text.Json; using Flurl.Http; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; using InnovEnergy.Lib.Devices.Sinexcel_12K_TL; using InnovEnergy.App.SinexcelCommunication.DataLogging; using InnovEnergy.App.SinexcelCommunication.ESS; using InnovEnergy.App.SinexcelCommunication.MiddlewareClasses; using InnovEnergy.App.SinexcelCommunication.SystemConfig; using InnovEnergy.Lib.Protocols.Modbus.Channels; using InnovEnergy.App.SinexcelCommunication.DataTypes; using InnovEnergy.Lib.Units; using InnovEnergy.Lib.Utils; using Newtonsoft.Json; using Formatting = Newtonsoft.Json.Formatting; using JsonSerializer = System.Text.Json.JsonSerializer; using static InnovEnergy.App.SinexcelCommunication.MiddlewareClasses.MiddlewareAgent; using System.Diagnostics.CodeAnalysis; using InnovEnergy.App.SinexcelCommunication.AggregationService; using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType; using InnovEnergy.Lib.Protocols.Modbus.Protocol; using static InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType.WorkingMode; #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. namespace InnovEnergy.App.SinexcelCommunication; [SuppressMessage("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic access otherwise can break functionality when trimming application code")] internal static class Program { private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(10); private const UInt16 NbrOfFileToConcatenate = 15; // add this to config file private static UInt16 _fileCounter = 0; private static List _sinexcelChannel; private static DateTime? _lastUploadedAggregatedDate; private static DailyEnergyData? _pendingDailyData; private static readonly String SwVersionNumber = " V1.00." + DateTime.Today; private const String VpnServerIp = "10.2.0.11"; private static Boolean _subscribedToQueue = false; private static Boolean _subscribeToQueueForTheFirstTime = false; private static SodistoreAlarmState _prevSodiohomeAlarmState = SodistoreAlarmState.Green; private static SodistoreAlarmState _sodiohomeAlarmState = SodistoreAlarmState.Green; // Tracking for error/warning content changes private static List _prevErrorCodes = new List(); private static List _prevWarningCodes = new List(); // Heartbeat tracking private static DateTime _lastHeartbeatTime = DateTime.MinValue; private const Int32 HeartbeatIntervalSeconds = 60; private const Byte SlaveId = 1; public static async Task Main(String[] args) { var config = Config.Load(); var d = config.Devices; var serial = d.Serial; Channel CreateChannel(SodiDevice device) => device.DeviceState == DeviceState.Disabled ? new NullChannel() : new SerialPortChannel(device.Port,serial.BaudRate,serial.Parity,serial.DataBits,serial.StopBits); _sinexcelChannel = new List() { CreateChannel(d.Inverter1), CreateChannel(d.Inverter2), CreateChannel(d.Inverter3), CreateChannel(d.Inverter4) }; InitializeCommunicationToMiddleware(); while (true) { try { await Run(); } catch (Exception e) { e.WriteLine(); } } // ReSharper disable once FunctionNeverReturns } private static async Task Run() { Watchdog.NotifyReady(); Console.WriteLine("Starting Sinexcel Communication, SW Version : " + SwVersionNumber); var devices = _sinexcelChannel .Where(ch => ch is not NullChannel) .Select(ch => new SinexcelDevice(ch, SlaveId)) .ToList(); StatusRecord? ReadStatus() { var config = Config.Load(); var listOfInverterRecord = devices .Select(device => device.Read()) .ToList(); InverterRecords? inverterRecords = InverterRecords.FromInverters(listOfInverterRecord); return new StatusRecord { InverterRecord = inverterRecords, 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 Battery Data *********************************************"); Console.WriteLine(startTime.ToString("HH:mm:ss.fff ")+ "Start Reading"); var statusrecord = ReadStatus(); if (statusrecord == null) return null; _ = CreateAggregatedData(statusrecord); var invDevices = statusrecord.InverterRecord?.Devices; if (invDevices != null) { var index = 1; foreach (var inverter in invDevices) PrintInverterData(inverter, index++); } SendSalimaxStateAlarm(GetSodiHomeStateAlarm(statusrecord),statusrecord); statusrecord.ControlConstants(); Console.WriteLine( " ************************************ We are writing ************************************"); var startWritingTime = DateTime.Now; Console.WriteLine(startWritingTime.ToString("HH:mm:ss.fff ") +"start Writing"); statusrecord?.Config.Save(); // save the config file if (statusrecord is { Config.ControlPermission: true, InverterRecord.Devices: not null }) { Console.WriteLine("We have the Right to Write"); foreach (var pair in devices.Zip(statusrecord.InverterRecord.Devices)) pair.First.Write(pair.Second); } else { Console.WriteLine("Nooooooo We can't have the Right to Write"); } var stop = DateTime.Now; Console.WriteLine("***************************** Writing finished *********************************************"); Console.WriteLine(stop.ToString("HH:mm:ss.fff ")+ "Cycle end"); return statusrecord; } catch (CrcException e) { Console.WriteLine(e); throw; // restart only on CRC } catch (Exception e) { Console.WriteLine(e); return null; // keep running for non-critical errors } } } // this is synchronous because : // // it only appends one line once per hour and once per day // // it should not meaningfully affect a 10-second loop private static async Task CreateAggregatedData(StatusRecord statusRecord) { DateTime now = DateTime.Now; string baseFolder = AppContext.BaseDirectory; // 1) Finalize previous hour if hour changed var hourlyData = EnergyAggregation.ProcessHourlyData(statusRecord, now); /*if (hourlyData != null) { AggregatedDataFileWriter.AppendHourlyData(hourlyData, baseFolder); }*/ if (hourlyData != null) { AggregatedDataFileWriter.AppendHourlyData(hourlyData, baseFolder); if (_pendingDailyData != null && hourlyData.Timestamp.Hour == 23) { AggregatedDataFileWriter.AppendDailyData(_pendingDailyData, baseFolder); _pendingDailyData = null; } } // 2) Save daily line near end of day var dailyData = EnergyAggregation.TryCreateDailyData(statusRecord, now); if (dailyData != null) { _pendingDailyData = dailyData; //AggregatedDataFileWriter.AppendDailyData(dailyData, baseFolder); } // 3) After midnight, upload yesterday's completed file once var yesterday = now.Date.AddDays(-1); if (now.Hour == 0 && now.Minute == 0) { if (_lastUploadedAggregatedDate != yesterday) { Console.WriteLine(" We are inside the lastuploaded Aggregate"); string filePath = Path.Combine( baseFolder, "AggregatedData", yesterday.ToString("ddMMyyyy") + ".json"); bool uploaded = await PushAggregatedFileToS3(filePath, statusRecord); if (uploaded) { _lastUploadedAggregatedDate = yesterday; Console.WriteLine($"Uploaded aggregated file for {yesterday:ddMMyyyy}"); } else { Console.WriteLine($"Uploaded failed for {yesterday:ddMMyyyy}"); } } } } private static async Task PushAggregatedFileToS3( String localFilePath, StatusRecord statusRecord) { var s3Config = statusRecord.Config.S3; if (s3Config is null) return false; try { if (!File.Exists(localFilePath)) { Console.WriteLine($"File not found: {localFilePath}"); return false; } var jsonString = await File.ReadAllTextAsync(localFilePath); // Example S3 object name: 09032026.json var s3Path = Path.GetFileName(localFilePath); var request = s3Config.CreatePutRequest(s3Path); var base64String = Convert.ToBase64String(Encoding.UTF8.GetBytes(jsonString)); using var content = new StringContent(base64String, Encoding.UTF8, "application/base64"); Console.WriteLine("Sending Content-Type: application/base64; charset=utf-8"); Console.WriteLine($"S3 Path: {s3Path}"); var response = await request.PutAsync(content); if (response.StatusCode != 200) { Console.WriteLine("ERROR: PUT"); var error = await response.GetStringAsync(); Console.WriteLine(error); return false; } Console.WriteLine($"Uploaded successfully: {s3Path}"); return true; } catch (Exception ex) { Console.WriteLine($"PushAggregatedFileToS3 failed: {ex.Message}"); return false; } } private static void ControlConstants(this StatusRecord? statusrecord) { if (statusrecord?.InverterRecord?.Devices == null) return; // Compute once (same for all inverters) var config = statusrecord.Config; var isChargePeriod = IsNowInsideDateAndTime( config.StartTimeChargeandDischargeDayandTime, config.StopTimeChargeandDischargeDayandTime ); foreach (var inverter in statusrecord.InverterRecord.Devices) { // constants for every inverter inverter.Battery1BackupSoc = (float)config.MinSoc; inverter.Battery2BackupSoc = (float)config.MinSoc; inverter.RepetitiveWeeks = SinexcelWeekDays.All; var operatingMode = config.OperatingPriority switch { OperatingPriority.LoadPriority => SpontaneousSelfUse, OperatingPriority.BatteryPriority => TimeChargeDischarge, OperatingPriority.GridPriority => PrioritySellElectricity, _ => SpontaneousSelfUse }; if (operatingMode!= TimeChargeDischarge) { inverter.WorkingMode = operatingMode; } else if (isChargePeriod) { inverter.WorkingMode = operatingMode; inverter.EffectiveStartDate = config.StartTimeChargeandDischargeDayandTime.Date; inverter.EffectiveEndDate = config.StopTimeChargeandDischargeDayandTime.Date; var power = config.TimeChargeandDischargePower; if (power > 0) { inverter.ChargingPowerPeriod1 = Math.Abs(power); inverter.ChargeStartTimePeriod1 = config.StartTimeChargeandDischargeDayandTime.TimeOfDay; inverter.ChargeEndTimePeriod1 = config.StopTimeChargeandDischargeDayandTime.TimeOfDay; inverter.DischargeStartTimePeriod1 = TimeSpan.Zero; inverter.DischargeEndTimePeriod1 = TimeSpan.Zero; } else { inverter.DishargingPowerPeriod1 = Math.Abs(power); inverter.DischargeStartTimePeriod1 = config.StartTimeChargeandDischargeDayandTime.TimeOfDay; inverter.DischargeEndTimePeriod1 = config.StopTimeChargeandDischargeDayandTime.TimeOfDay; inverter.ChargeStartTimePeriod1 = TimeSpan.Zero; inverter.ChargeEndTimePeriod1 = TimeSpan.Zero; } } else { inverter.WorkingMode = SpontaneousSelfUse; } inverter.PowerOn = 1; inverter.PowerOff = 0; } } static void PrintInverterData(SinexcelRecord r, int index) { Console.WriteLine($" ************************************************ Inverter {index} ************************************************ "); //Console.WriteLine($"{r.SystemDateTime} SystemDateTime"); Console.WriteLine($"{r.TotalPhotovoltaicPower} TotalPhotovoltaicPower"); Console.WriteLine($"{r.TotalBatteryPower} TotalBatteryPower"); Console.WriteLine($"{r.TotalLoadPower} TotalLoadPower"); Console.WriteLine($"{r.TotalGridPower} TotalGridPower"); Console.WriteLine($"{r.Battery1Power} Battery1Power"); Console.WriteLine($"{r.Battery1Soc} Battery1Soc"); Console.WriteLine($"{r.Battery1BackupSoc} Battery1BackupSoc"); Console.WriteLine($"{r.Battery1MinSoc} Battery1MinSoc"); Console.WriteLine($"{r.Battery2Power} Battery2Power"); Console.WriteLine($"{r.Battery2Soc} Battery2Soc"); Console.WriteLine($"{r.Battery2BackupSoc} Battery2BackupSoc"); Console.WriteLine($"{r.Battery2MinSoc} Battery2MinSoc"); Console.WriteLine($"{r.EnableGridExport} EnableGridExport"); Console.WriteLine($"{r.PowerGridExportLimit} PowerGridExportLimit"); Console.WriteLine($"{r.PowerOn} PowerOn"); Console.WriteLine($"{r.PowerOff} PowerOff"); Console.WriteLine($"{r.WorkingMode} WorkingMode"); Console.WriteLine($"{r.GridSwitchMethod} GridSwitchMethod"); Console.WriteLine($"{r.ThreePhaseWireSystem} ThreePhaseWireSystem"); Console.WriteLine(); } private static bool IsNowInsideDateAndTime(DateTime effectiveStart, DateTime effectiveEnd) { DateTime now = DateTime.Now; // Date check if (now < effectiveStart || now > effectiveEnd) return false; return true; } private static StatusMessage GetSodiHomeStateAlarm(StatusRecord? record) { var s3Bucket = record?.Config.S3?.Bucket; // this should not load the config file, only use the one from status record TO change this in other project var alarmList = new List(); var warningList = new List(); /* if (record.SinexcelRecord.WorkingMode == 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; } /// /// Checks if the error or warning content has changed compared to the previous state. /// This allows detection of new/cleared errors even when the overall alarm state (Red/Orange/Green) remains the same. /// private static Boolean HasErrorsOrWarningsChanged(StatusMessage currentState) { // Get current error codes (descriptions) var currentErrors = currentState.Alarms? .Select(a => a.Description ?? String.Empty) .OrderBy(d => d) // Sort for consistent comparison .ToList() ?? new List(); // Get current warning codes (descriptions) var currentWarnings = currentState.Warnings? .Select(w => w.Description ?? String.Empty) .OrderBy(d => d) // Sort for consistent comparison .ToList() ?? new List(); // Check if lists have changed (new items added or existing items removed) var errorsChanged = !currentErrors.SequenceEqual(_prevErrorCodes); var warningsChanged = !currentWarnings.SequenceEqual(_prevWarningCodes); // Update tracking if changes detected if (errorsChanged || warningsChanged) { Console.WriteLine($"Error/Warning content changed:"); Console.WriteLine($" Errors: {String.Join(", ", currentErrors)} (was: {String.Join(", ", _prevErrorCodes)})"); Console.WriteLine($" Warnings: {String.Join(", ", currentWarnings)} (was: {String.Join(", ", _prevWarningCodes)})"); _prevErrorCodes = currentErrors; _prevWarningCodes = currentWarnings; return true; } return false; } 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); _lastHeartbeatTime = DateTime.Now; // Initialize heartbeat timer } // Check if we should send a message var stateChanged = currentSalimaxState.Status != _prevSodiohomeAlarmState; var contentChanged = HasErrorsOrWarningsChanged(currentSalimaxState); var needsHeartbeat = (DateTime.Now - _lastHeartbeatTime).TotalSeconds >= HeartbeatIntervalSeconds; if (s3Bucket == null) { Console.WriteLine("⚠ S3 bucket not configured. Skipping middleware send."); LogMiddlewareFailure(new Exception("S3 Bucket not configured")); return; } // Ensure connection FIRST if (!RabbitMqManager.EnsureConnected(currentSalimaxState, s3Bucket, VpnServerIp)) { Console.WriteLine($"❌ RabbitMQ EnsureConnected FAILED at {DateTime.Now:HH:mm:ss.fff}"); LogMiddlewareFailure(new Exception("EnsureConnected returned false")); return; } //If already subscribed to the queue and the status has been changed, update the queue if (!subscribedNow && (stateChanged || contentChanged || needsHeartbeat)) { _prevSodiohomeAlarmState = currentSalimaxState.Status; // Set appropriate message type if (stateChanged || contentChanged) { currentSalimaxState.Type = MessageType.AlarmOrWarning; Console.WriteLine($"Sending AlarmOrWarning message - StateChanged: {stateChanged}, ContentChanged: {contentChanged}"); } else if (needsHeartbeat) { currentSalimaxState.Type = MessageType.Heartbit; Console.WriteLine($"Sending Heartbeat message - {HeartbeatIntervalSeconds}s interval reached"); _lastHeartbeatTime = DateTime.Now; } try { RabbitMqManager.InformMiddleware(currentSalimaxState); LogMiddlewareFailure(new Exception($"✅ Middleware message sent at {DateTime.Now:HH:mm:ss.fff}")); } catch (Exception ex) { Console.WriteLine($"❌ Failed to send middleware message: {ex.Message}"); LogMiddlewareFailure(ex); } } //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 LogMiddlewareFailure(Exception ex) { try { var logPath = "/home/inesco/SodiStoreHome/middleware_failures.log"; var logEntry = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}]\n" + $"Exception: {ex.GetType().FullName}\n" + $"Message: {ex.Message}\n" + $"StackTrace:\n{ex.StackTrace}\n" + $"--------------------------------------------------\n"; File.AppendAllText(logPath, logEntry); } catch { // Never allow logging to crash the service } } private static void ApplyConfigFile(this StatusRecord? status, Configuration? config) { if (config == null) return; if (status == null) return; status.Config.MinSoc = config.MinimumSoC; status.Config.MaximumChargingCurrent = config.MaximumChargingCurrent; status.Config.MaximumDischargingCurrent = config.MaximumDischargingCurrent; status.Config.OperatingPriority = config.OperatingPriority; status.Config.BatteriesCount = config.BatteriesCount; status.Config.ClusterNumber = config.ClusterNumber; status.Config.StartTimeChargeandDischargeDayandTime = config.StartTimeChargeandDischargeDayandTime; status.Config.StopTimeChargeandDischargeDayandTime = config.StopTimeChargeandDischargeDayandTime; status.Config.TimeChargeandDischargePower = config.TimeChargeandDischargePower; status.Config.PvNumber = config.PvNumber; status.Config.ControlPermission = config.ControlPermission; } private static async Task SaveModbusTcpFile(StatusRecord status) { var modbusData = new Dictionary(); try { // SYSTEM DATA var result1 = ConvertToModbusRegisters((status.Config.ModbusProtcolNumber * 10), "UInt16", 30001); // this to be updated to modbusTCP version var result2 = ConvertToModbusRegisters(DateTimeOffset.Now.ToUnixTimeSeconds(), "UInt32", 30002); // SYSTEM DATA var result3 = ConvertToModbusRegisters(status.InverterRecord.OperatingPriority, "UInt16", 30004); var result4 = ConvertToModbusRegisters((status.Config.BatteriesCount), "UInt16", 31000); var result8 = ConvertToModbusRegisters((0), "UInt16", 31001); // this is ignored as dosen't exist in Sinexcel var result12 = ConvertToModbusRegisters((status.InverterRecord.AvgBatteryVoltage.Value * 10), "Int16", 31002); var result13 = ConvertToModbusRegisters((status.InverterRecord.TotalBatteryCurrent.Value * 10), "Int32", 31003); var result16 = ConvertToModbusRegisters((status.InverterRecord.AvgBatterySoc.Value * 100), "UInt16", 31005); var result9 = ConvertToModbusRegisters((status.InverterRecord.TotalBatteryPower.Value * 10), "Int32", 31006); var result14 = ConvertToModbusRegisters((status.InverterRecord.MinSoc.Value * 100), "UInt16", 31008); var result55 = ConvertToModbusRegisters(100 * 100, "UInt16", 31009); //this is ignored as dosen't exist in Sinexcel var result5 = ConvertToModbusRegisters((status.InverterRecord.AvgBatterySoh.Value * 100), "UInt16", 31009); var result7 = ConvertToModbusRegisters((status.InverterRecord.AvgBatteryTemp.Value * 100), "Int16", 31011); var result20 = ConvertToModbusRegisters((status.InverterRecord.MaxChargeCurrent.Value * 10), "UInt16", 31012); var result15 = ConvertToModbusRegisters((status.InverterRecord.MaxDischargingCurrent.Value * 10), "UInt16", 31013); var result26 = ConvertToModbusRegisters(60 * 10, "UInt16", 31014); //this is ignored as dosen't exist in Sinexcel var result18 = ConvertToModbusRegisters((status.InverterRecord.TotalPhotovoltaicPower.Value * 10), "UInt32", 32000); var result19 = ConvertToModbusRegisters((status.InverterRecord.TotalGridPower.Value * 10), "Int32", 33000); var result23 = ConvertToModbusRegisters((status.InverterRecord.GridFrequency.Value * 10), "UInt16", 33002); var result24 = ConvertToModbusRegisters((status.InverterRecord.OperatingPriority), "UInt16", 34000); var result25 = ConvertToModbusRegisters((status.InverterRecord.InverterPower.Value * 10), "Int32", 34001); var result29 = ConvertToModbusRegisters((status.InverterRecord.EnableGridExport ), "UInt16", 35002); var result27 = ConvertToModbusRegisters((status.InverterRecord.GridExportPower.Value ), "Int16", 35003); // Merge all results into one dictionary var allResults = new[] { result1, result2, result3, result4, result5, result23, result24, result25, result29, result27, result26, result7, result8, result9, result16, result20, result12, result13, result14, result15, result18, result19 , result55 }; 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); return true; } catch (Exception e) { Console.WriteLine(e); throw; } } private static Dictionary ConvertToModbusRegisters(object value, string outputType, int startingAddress) { var regs = new Dictionary(capacity: 2); switch (outputType) { case "UInt16": { regs[$"{startingAddress}"] = Convert.ToUInt16(value); break; } case "Int16": { short v = Convert.ToInt16(value); regs[$"{startingAddress}"] = unchecked((ushort)v); // reinterpret break; } case "UInt32": { uint v = Convert.ToUInt32(value); ushort hi = (ushort)(v >> 16); ushort lo = (ushort)(v & 0xFFFF); regs[$"{startingAddress}"] = hi; // HIGH word first (Modbus standard) regs[$"{startingAddress + 1}"] = lo; // then LOW word break; } case "Int32": { int v = Convert.ToInt32(value); uint raw = unchecked((uint)v); // bit-reinterpret ushort hi = (ushort)(raw >> 16); ushort lo = (ushort)(raw & 0xFFFF); regs[$"{startingAddress}"] = hi; // HIGH word regs[$"{startingAddress + 1}"] = lo; // LOW word break; } case "Float": // IEEE-754 single { float f = Convert.ToSingle(value); // Convert to bytes, then to two big-endian 16-bit words var bytes = BitConverter.GetBytes(f); // little-endian on most platforms Array.Reverse(bytes); // to big-endian byte order ushort hi = (ushort)((bytes[0] << 8) | bytes[1]); ushort lo = (ushort)((bytes[2] << 8) | bytes[3]); regs[$"{startingAddress}"] = hi; regs[$"{startingAddress + 1}"] = lo; break; } default: throw new ArgumentException($"Unsupported output type: {outputType}"); } return regs; } 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 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 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 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; } }