1036 lines
41 KiB
C#
1036 lines
41 KiB
C#
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<Channel> _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<String> _prevErrorCodes = new List<String>();
|
|
private static List<String> _prevWarningCodes = new List<String>();
|
|
|
|
// 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<Channel>()
|
|
{
|
|
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<Boolean> 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<AlarmOrWarning>();
|
|
var warningList = new List<AlarmOrWarning>();
|
|
/*
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<String>();
|
|
|
|
// 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<String>();
|
|
|
|
// 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<Boolean> SaveModbusTcpFile(StatusRecord status)
|
|
{
|
|
var modbusData = new Dictionary<String, UInt16>();
|
|
|
|
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<string, ushort> ConvertToModbusRegisters(object value, string outputType, int startingAddress)
|
|
{
|
|
var regs = new Dictionary<string, ushort>(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<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 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];
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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<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 = 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;
|
|
}
|
|
|
|
} |