From efa8454dddcd113aca2badac5a4994928543628d Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:15:59 +0200 Subject: [PATCH 01/22] Aggreagte data for Sinexcel installation --- .../AggregateDataFileWriter.cs | 76 +++++++++++++++++++ .../AggregateDataFileWriter.cs | 75 ++++++++++++++++++ .../src/AggregationService/Aggregator.cs | 2 +- 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 csharp/App/GrowattCommunication/AggregationService/AggregateDataFileWriter.cs create mode 100644 csharp/App/SinexcelCommunication/AggregationService/AggregateDataFileWriter.cs diff --git a/csharp/App/GrowattCommunication/AggregationService/AggregateDataFileWriter.cs b/csharp/App/GrowattCommunication/AggregationService/AggregateDataFileWriter.cs new file mode 100644 index 000000000..b289b82d7 --- /dev/null +++ b/csharp/App/GrowattCommunication/AggregationService/AggregateDataFileWriter.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace InnovEnergy.App.GrowattCommunication.AggregationService; + + +public class HourlyEnergyData +{ + public String Type { get; set; } = "Hourly"; + public DateTime Timestamp { get; set; } + + public double SelfGeneratedElectricity { get; set; } + public double ElectricityPurchased { get; set; } + public double ElectricityFed { get; set; } + public double BatteryChargeEnergy { get; set; } + public double BatteryDischargeEnergy { get; set; } + public double LoadPowerConsumption { get; set; } +} + +public class DailyEnergyData +{ + public String Type { get; set; } = "Daily"; + public DateTime Timestamp { get; set; } + + public double DailySelfGeneratedElectricity { get; set; } + public double DailyElectricityPurchased { get; set; } + public double DailyElectricityFed { get; set; } + public double BatteryDailyChargeEnergy { get; set; } + public double BatteryDailyDischargeEnergy { get; set; } + public double DailyLoadPowerConsumption { get; set; } +} + +public static class AggregatedDataFileWriter +{ + private static bool _folderCreated = false; + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + WriteIndented = false + }; + + public static void AppendHourlyData(HourlyEnergyData data, string baseFolder) + { + var filePath = GetDailyFilePath(data.Timestamp, baseFolder); + AppendJsonLine(filePath, data); + Console.WriteLine($"Hourly data appended to {filePath}"); + } + + public static void AppendDailyData(DailyEnergyData data, string baseFolder) + { + var filePath = GetDailyFilePath(data.Timestamp, baseFolder); + AppendJsonLine(filePath, data); + Console.WriteLine($"Daily data appended to {filePath}"); + } + + private static string GetDailyFilePath(DateTime timestamp, string baseFolder) + { + var folder = Path.Combine(baseFolder, "AggregatedData"); + + if (!_folderCreated) + { + Directory.CreateDirectory(folder); + _folderCreated = true; + } + + var fileName = timestamp.ToString("ddMMyyyy") + ".json"; + return Path.Combine(folder, fileName); + } + + private static void AppendJsonLine(string filePath, T data) + { + var json = JsonSerializer.Serialize(data, JsonOptions); + File.AppendAllText(filePath, json + Environment.NewLine); + } +} \ No newline at end of file diff --git a/csharp/App/SinexcelCommunication/AggregationService/AggregateDataFileWriter.cs b/csharp/App/SinexcelCommunication/AggregationService/AggregateDataFileWriter.cs new file mode 100644 index 000000000..a83f26f69 --- /dev/null +++ b/csharp/App/SinexcelCommunication/AggregationService/AggregateDataFileWriter.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace InnovEnergy.App.SinexcelCommunication.AggregationService; + +public class HourlyEnergyData +{ + public String Type { get; set; } = "Hourly"; + public DateTime Timestamp { get; set; } + + public double SelfGeneratedElectricity { get; set; } + public double ElectricityPurchased { get; set; } + public double ElectricityFed { get; set; } + public double BatteryChargeEnergy { get; set; } + public double BatteryDischargeEnergy { get; set; } + public double LoadPowerConsumption { get; set; } +} + +public class DailyEnergyData +{ + public String Type { get; set; } = "Daily"; + public DateTime Timestamp { get; set; } + + public double DailySelfGeneratedElectricity { get; set; } + public double DailyElectricityPurchased { get; set; } + public double DailyElectricityFed { get; set; } + public double BatteryDailyChargeEnergy { get; set; } + public double BatteryDailyDischargeEnergy { get; set; } + public double DailyLoadPowerConsumption { get; set; } +} + +public static class AggregatedDataFileWriter +{ + private static bool _folderCreated = false; + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + WriteIndented = false + }; + + public static void AppendHourlyData(HourlyEnergyData data, string baseFolder) + { + var filePath = GetDailyFilePath(data.Timestamp, baseFolder); + AppendJsonLine(filePath, data); + Console.WriteLine($"Hourly data appended to {filePath}"); + } + + public static void AppendDailyData(DailyEnergyData data, string baseFolder) + { + var filePath = GetDailyFilePath(data.Timestamp, baseFolder); + AppendJsonLine(filePath, data); + Console.WriteLine($"Daily data appended to {filePath}"); + } + + private static string GetDailyFilePath(DateTime timestamp, string baseFolder) + { + var folder = Path.Combine(baseFolder, "AggregatedData"); + + if (!_folderCreated) + { + Directory.CreateDirectory(folder); + _folderCreated = true; + } + + var fileName = timestamp.ToString("ddMMyyyy") + ".json"; + return Path.Combine(folder, fileName); + } + + private static void AppendJsonLine(string filePath, T data) + { + var json = JsonSerializer.Serialize(data, JsonOptions); + File.AppendAllText(filePath, json + Environment.NewLine); + } +} \ No newline at end of file diff --git a/csharp/App/SodiStoreMax/src/AggregationService/Aggregator.cs b/csharp/App/SodiStoreMax/src/AggregationService/Aggregator.cs index b857fb6ca..7aecd0cc5 100644 --- a/csharp/App/SodiStoreMax/src/AggregationService/Aggregator.cs +++ b/csharp/App/SodiStoreMax/src/AggregationService/Aggregator.cs @@ -70,7 +70,7 @@ public static class Aggregator dailyAggregatedData.Save("DailyData"); if (await dailyAggregatedData.PushToS3()) { - DeleteHourlyData("HourlyData",currentTime.ToUnixTime()); + //DeleteHourlyData("HourlyData",currentTime.ToUnixTime()); //AggregatedData.DeleteDailyData("DailyData"); } From d97167316ae7247ca0910f01c6b77c1eecba058d Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:17:17 +0200 Subject: [PATCH 02/22] IO Lib and standalone project created --- .../GpioTestingProject.csproj | 16 ++++ csharp/InnovEnergy.sln | 92 ++++++++++++++----- .../Devices/GPIORaspberryPI4/DigitalInput.cs | 57 ++++++++++++ .../GPIORaspberryPI4/GPIORaspberryPI4.csproj | 13 +++ .../Devices/GPIORaspberryPI4/IDigitalInput.cs | 8 ++ .../Devices/GPIORaspberryPI4/IRelayOutput.cs | 9 ++ .../Devices/GPIORaspberryPI4/RelayOutput.cs | 81 ++++++++++++++++ 7 files changed, 251 insertions(+), 25 deletions(-) create mode 100644 csharp/App/GpioTestingProject/GpioTestingProject.csproj create mode 100644 csharp/Lib/Devices/GPIORaspberryPI4/DigitalInput.cs create mode 100644 csharp/Lib/Devices/GPIORaspberryPI4/GPIORaspberryPI4.csproj create mode 100644 csharp/Lib/Devices/GPIORaspberryPI4/IDigitalInput.cs create mode 100644 csharp/Lib/Devices/GPIORaspberryPI4/IRelayOutput.cs create mode 100644 csharp/Lib/Devices/GPIORaspberryPI4/RelayOutput.cs diff --git a/csharp/App/GpioTestingProject/GpioTestingProject.csproj b/csharp/App/GpioTestingProject/GpioTestingProject.csproj new file mode 100644 index 000000000..a4f20b48e --- /dev/null +++ b/csharp/App/GpioTestingProject/GpioTestingProject.csproj @@ -0,0 +1,16 @@ + + + + InnovEnergy.App.GpioTestingProject + + + + + + + + + + + + diff --git a/csharp/InnovEnergy.sln b/csharp/InnovEnergy.sln index 6c48122ab..91dc5abf0 100644 --- a/csharp/InnovEnergy.sln +++ b/csharp/InnovEnergy.sln @@ -1,27 +1,26 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Collector", "App\Collector\Collector.csproj", "{E3A5F3A3-72A5-47CC-85C6-2D8E962A0EC1}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Collector", "App/Collector/Collector.csproj", "{E3A5F3A3-72A5-47CC-85C6-2D8E962A0EC1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenVpnCertificatesServer", "App\OpenVpnCertificatesServer\OpenVpnCertificatesServer.csproj", "{CF4834CB-91B7-4172-AC13-ECDA8613CD17}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenVpnCertificatesServer", "App/OpenVpnCertificatesServer/OpenVpnCertificatesServer.csproj", "{CF4834CB-91B7-4172-AC13-ECDA8613CD17}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RemoteSupportConsole", "App\RemoteSupportConsole\RemoteSupportConsole.csproj", "{B1268C03-66EB-4486-8BFC-B439225D9D54}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RemoteSupportConsole", "App/RemoteSupportConsole/RemoteSupportConsole.csproj", "{B1268C03-66EB-4486-8BFC-B439225D9D54}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SysTools", "Lib\SysTools\SysTools.csproj", "{4A67D79F-F0C9-4BBC-9601-D5948E6C05D3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SysTools", "Lib/SysTools/SysTools.csproj", "{4A67D79F-F0C9-4BBC-9601-D5948E6C05D3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebServer", "Lib\WebServer\WebServer.csproj", "{B2627B9F-41DF-44F7-A0D1-CA71FF4A007A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebServer", "Lib/WebServer/WebServer.csproj", "{B2627B9F-41DF-44F7-A0D1-CA71FF4A007A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmuMeterDriver", "App\EmuMeterDriver\EmuMeterDriver.csproj", "{F65F33B0-3522-4008-8D1E-47EF8E4C7AC7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmuMeterDriver", "App/EmuMeterDriver/EmuMeterDriver.csproj", "{F65F33B0-3522-4008-8D1E-47EF8E4C7AC7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BmsTunnel", "App\BmsTunnel\BmsTunnel.csproj", "{40B45363-BE34-420B-8F87-775EE6EE3513}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BmsTunnel", "App/BmsTunnel/BmsTunnel.csproj", "{40B45363-BE34-420B-8F87-775EE6EE3513}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "App", "App", "{145597B4-3E30-45E6-9F72-4DD43194539A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Lib", "Lib", "{AD5B98A8-AB7F-4DA2-B66D-5B4E63E7D854}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SaliMax", "App\SaliMax\SaliMax.csproj", "{25073794-D859-4824-9984-194C7E928496}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SaliMax", "App/SaliMax/SaliMax.csproj", "{25073794-D859-4824-9984-194C7E928496}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusApi", "Lib\StatusApi\StatusApi.csproj", "{9D17E78C-8A70-43DB-A619-DC12D20D023D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StatusApi", "Lib/StatusApi/StatusApi.csproj", "{9D17E78C-8A70-43DB-A619-DC12D20D023D}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Devices", "Devices", "{4931A385-24DC-4E78-BFF4-356F8D6D5183}" EndProject @@ -31,35 +30,35 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Victron", "Victron", "{BD8C EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Trumpf", "Trumpf", "{DDDBEFD0-5DEA-4C7C-A9F2-FDB4636CF092}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TruConvertAc", "Lib\Devices\Trumpf\TruConvertAc\TruConvertAc.csproj", "{1F4B445E-459E-44CD-813E-6D725EBB81E8}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TruConvertAc", "Lib/Devices/Trumpf/TruConvertAc/TruConvertAc.csproj", "{1F4B445E-459E-44CD-813E-6D725EBB81E8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TruConvertDc", "Lib\Devices\Trumpf\TruConvertDc\TruConvertDc.csproj", "{F6F29829-C31A-4994-A698-E441BEA631C6}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TruConvertDc", "Lib/Devices/Trumpf/TruConvertDc/TruConvertDc.csproj", "{F6F29829-C31A-4994-A698-E441BEA631C6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DBus", "Lib\Protocols\DBus\DBus.csproj", "{8C3C620A-087D-4DD6-B493-A47FC643F8DC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DBus", "Lib/Protocols/DBus/DBus.csproj", "{8C3C620A-087D-4DD6-B493-A47FC643F8DC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modbus", "Lib\Protocols\Modbus\Modbus.csproj", "{E4AE6A33-0DEB-48EB-9D57-C0C7C63FC267}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Modbus", "Lib/Protocols/Modbus/Modbus.csproj", "{E4AE6A33-0DEB-48EB-9D57-C0C7C63FC267}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VeDBus", "Lib\Victron\VeDBus\VeDBus.csproj", "{50B26E29-1B99-4D07-BCA5-359CD550BBAA}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VeDBus", "Lib/Victron/VeDBus/VeDBus.csproj", "{50B26E29-1B99-4D07-BCA5-359CD550BBAA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VictronVRM", "Lib\Victron\VictronVRM\VictronVRM.csproj", "{FE05DF69-B5C7-4C2E-8FB9-7776441A7622}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VictronVRM", "Lib/Victron/VictronVRM/VictronVRM.csproj", "{FE05DF69-B5C7-4C2E-8FB9-7776441A7622}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ampt", "Lib\Devices\AMPT\Ampt.csproj", "{77AF3A64-2878-4150-BCD0-F16530783165}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ampt", "Lib/Devices/AMPT/Ampt.csproj", "{77AF3A64-2878-4150-BCD0-F16530783165}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Battery48TL", "Lib\Devices\Battery48TL\Battery48TL.csproj", "{1C3F443A-B339-4B08-80E6-8A84817FFEC9}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Battery48TL", "Lib/Devices/Battery48TL/Battery48TL.csproj", "{1C3F443A-B339-4B08-80E6-8A84817FFEC9}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmuMeter", "Lib\Devices\EmuMeter\EmuMeter.csproj", "{152A4168-F612-493C-BBEA-8EB26E6E2D34}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmuMeter", "Lib/Devices/EmuMeter/EmuMeter.csproj", "{152A4168-F612-493C-BBEA-8EB26E6E2D34}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Utils", "Lib\Utils\Utils.csproj", "{89A3E29C-4E57-47FE-A800-12AC68418264}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Utils", "Lib/Utils/Utils.csproj", "{89A3E29C-4E57-47FE-A800-12AC68418264}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Adam6060", "Lib\Devices\Adam6060\Adam6060.csproj", "{4AFDB799-E6A4-4DCA-8B6D-8C0F98398461}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Adam6060", "Lib/Devices/Adam6060/Adam6060.csproj", "{4AFDB799-E6A4-4DCA-8B6D-8C0F98398461}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Channels", "Lib\Channels\Channels.csproj", "{AF7E8DCA-8D48-498E-AB3D-208061B244DC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Channels", "Lib/Channels/Channels.csproj", "{AF7E8DCA-8D48-498E-AB3D-208061B244DC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend", "App\Backend\Backend.csproj", "{A56F58C2-B265-435B-A985-53B4D6F49B1A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend", "App/Backend/Backend.csproj", "{A56F58C2-B265-435B-A985-53B4D6F49B1A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Units", "Lib\Units\Units.csproj", "{C04FB6DA-23C6-46BB-9B21-8F4FBA32FFF7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Units", "Lib/Units/Units.csproj", "{C04FB6DA-23C6-46BB-9B21-8F4FBA32FFF7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SystemControl", "Lib\Devices\Trumpf\SystemControl\SystemControl.csproj", "{B816BB44-E97E-4E02-B80A-BEDB5B923A96}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SystemControl", "Lib/Devices/Trumpf/SystemControl/SystemControl.csproj", "{B816BB44-E97E-4E02-B80A-BEDB5B923A96}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Meta", "Meta", "{AED84693-C389-44C9-B2C0-ACB560189CF2}" ProjectSection(SolutionItems) = preProject @@ -88,6 +87,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Doepke", "Lib\Devices\Doepk EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amax5070", "Lib\Devices\Amax5070\Amax5070.csproj", "{09E280B0-43D3-47BD-AF15-CF4FCDD24FE6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SofarInverter", "Lib\Devices\SofarInverter\SofarInverter.csproj", "{2C7F3D89-402B-43CB-988E-8D2D853BEF44}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SchneiderMeterDriver", "App\SchneiderMeterDriver\SchneiderMeterDriver.csproj", "{2E7E7657-3A53-4B62-8927-FE9A082B81DE}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Battery250UP", "Lib\Devices\Battery250UP\Battery250UP.csproj", "{F2967439-A590-4D5E-9208-1B973C83AA1C}" @@ -108,6 +109,17 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SinexcelCommunication", "Ap EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sinexcel 12K TL", "Sinexcel 12K TL\Sinexcel 12K TL.csproj", "{28C16B43-E498-40DB-8ACF-D7F2A88A402F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kaco92L3", "Lib\Devices\Kaco92L3\Kaco92L3.csproj", "{E60412AA-F88C-4CB7-AEFC-78427B1ADA13}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KacoCommunication", "App\KacoCommunication\KacoCommunication.csproj", "{0380E4B0-2A0C-4E3B-8536-499B72B23179}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PLVario2Meter", "Lib\Devices\PLVario2Meter\PLVario2Meter.csproj", "{D6D07FC5-2925-4B13-9F65-22123E07F8CC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GPIORaspberryPI4", "Lib\Devices\GPIORaspberryPI4\GPIORaspberryPI4.csproj", "{5E7A867E-D026-43B4-BDB9-240E4331CA23}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GpioTestingProject", "App\GpioTestingProject\GpioTestingProject.csproj", "{C6E3B901-3730-4B04-B821-85A6673C3D25}" +EndProject + Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -246,6 +258,10 @@ Global {09E280B0-43D3-47BD-AF15-CF4FCDD24FE6}.Debug|Any CPU.Build.0 = Debug|Any CPU {09E280B0-43D3-47BD-AF15-CF4FCDD24FE6}.Release|Any CPU.ActiveCfg = Release|Any CPU {09E280B0-43D3-47BD-AF15-CF4FCDD24FE6}.Release|Any CPU.Build.0 = Release|Any CPU + {2C7F3D89-402B-43CB-988E-8D2D853BEF44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C7F3D89-402B-43CB-988E-8D2D853BEF44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C7F3D89-402B-43CB-988E-8D2D853BEF44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C7F3D89-402B-43CB-988E-8D2D853BEF44}.Release|Any CPU.Build.0 = Release|Any CPU {2E7E7657-3A53-4B62-8927-FE9A082B81DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2E7E7657-3A53-4B62-8927-FE9A082B81DE}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E7E7657-3A53-4B62-8927-FE9A082B81DE}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -286,6 +302,26 @@ Global {28C16B43-E498-40DB-8ACF-D7F2A88A402F}.Debug|Any CPU.Build.0 = Debug|Any CPU {28C16B43-E498-40DB-8ACF-D7F2A88A402F}.Release|Any CPU.ActiveCfg = Release|Any CPU {28C16B43-E498-40DB-8ACF-D7F2A88A402F}.Release|Any CPU.Build.0 = Release|Any CPU + {E60412AA-F88C-4CB7-AEFC-78427B1ADA13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E60412AA-F88C-4CB7-AEFC-78427B1ADA13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E60412AA-F88C-4CB7-AEFC-78427B1ADA13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E60412AA-F88C-4CB7-AEFC-78427B1ADA13}.Release|Any CPU.Build.0 = Release|Any CPU + {0380E4B0-2A0C-4E3B-8536-499B72B23179}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0380E4B0-2A0C-4E3B-8536-499B72B23179}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0380E4B0-2A0C-4E3B-8536-499B72B23179}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0380E4B0-2A0C-4E3B-8536-499B72B23179}.Release|Any CPU.Build.0 = Release|Any CPU + {D6D07FC5-2925-4B13-9F65-22123E07F8CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6D07FC5-2925-4B13-9F65-22123E07F8CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6D07FC5-2925-4B13-9F65-22123E07F8CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6D07FC5-2925-4B13-9F65-22123E07F8CC}.Release|Any CPU.Build.0 = Release|Any CPU + {5E7A867E-D026-43B4-BDB9-240E4331CA23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5E7A867E-D026-43B4-BDB9-240E4331CA23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E7A867E-D026-43B4-BDB9-240E4331CA23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5E7A867E-D026-43B4-BDB9-240E4331CA23}.Release|Any CPU.Build.0 = Release|Any CPU + {C6E3B901-3730-4B04-B821-85A6673C3D25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6E3B901-3730-4B04-B821-85A6673C3D25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6E3B901-3730-4B04-B821-85A6673C3D25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6E3B901-3730-4B04-B821-85A6673C3D25}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {CF4834CB-91B7-4172-AC13-ECDA8613CD17} = {145597B4-3E30-45E6-9F72-4DD43194539A} @@ -325,6 +361,7 @@ Global {73B97F6E-2BDC-40DA-84A7-7FB0264387D6} = {AD5B98A8-AB7F-4DA2-B66D-5B4E63E7D854} {C2B14CD4-1BCA-4933-96D9-92F40EACD2B9} = {4931A385-24DC-4E78-BFF4-356F8D6D5183} {09E280B0-43D3-47BD-AF15-CF4FCDD24FE6} = {4931A385-24DC-4E78-BFF4-356F8D6D5183} + {2C7F3D89-402B-43CB-988E-8D2D853BEF44} = {4931A385-24DC-4E78-BFF4-356F8D6D5183} {2E7E7657-3A53-4B62-8927-FE9A082B81DE} = {145597B4-3E30-45E6-9F72-4DD43194539A} {F2967439-A590-4D5E-9208-1B973C83AA1C} = {4931A385-24DC-4E78-BFF4-356F8D6D5183} {1045AC74-D4D8-4581-AAE3-575DF26060E6} = {4931A385-24DC-4E78-BFF4-356F8D6D5183} @@ -335,5 +372,10 @@ Global {6069D487-DBAB-4253-BFA1-CF994B84BE49} = {145597B4-3E30-45E6-9F72-4DD43194539A} {93084D79-2977-47A1-9CAC-3E2DC6423F5B} = {145597B4-3E30-45E6-9F72-4DD43194539A} {28C16B43-E498-40DB-8ACF-D7F2A88A402F} = {4931A385-24DC-4E78-BFF4-356F8D6D5183} + {E60412AA-F88C-4CB7-AEFC-78427B1ADA13} = {4931A385-24DC-4E78-BFF4-356F8D6D5183} + {0380E4B0-2A0C-4E3B-8536-499B72B23179} = {145597B4-3E30-45E6-9F72-4DD43194539A} + {D6D07FC5-2925-4B13-9F65-22123E07F8CC} = {4931A385-24DC-4E78-BFF4-356F8D6D5183} + {5E7A867E-D026-43B4-BDB9-240E4331CA23} = {4931A385-24DC-4E78-BFF4-356F8D6D5183} + {C6E3B901-3730-4B04-B821-85A6673C3D25} = {145597B4-3E30-45E6-9F72-4DD43194539A} EndGlobalSection EndGlobal diff --git a/csharp/Lib/Devices/GPIORaspberryPI4/DigitalInput.cs b/csharp/Lib/Devices/GPIORaspberryPI4/DigitalInput.cs new file mode 100644 index 000000000..26301fadd --- /dev/null +++ b/csharp/Lib/Devices/GPIORaspberryPI4/DigitalInput.cs @@ -0,0 +1,57 @@ +using System; +using System.Device.Gpio; + +namespace GPIORaspberryPI4; + +public sealed class DigitalInput : IDigitalInput +{ + private readonly GpioController _gpio; + private readonly int _pin; + private readonly bool _activeLow; + private bool _disposed; + + public int Pin => _pin; + + public bool IsActive => Read(); + + public DigitalInput(int pin, bool pullUp = true, bool activeLow = true) + { + _pin = pin; + _activeLow = activeLow; + + _gpio = new GpioController(); + + var mode = pullUp ? PinMode.InputPullUp : PinMode.Input; + + _gpio.OpenPin(_pin, mode); + } + + public bool Read() + { + ThrowIfDisposed(); + + var value = _gpio.Read(_pin); + + return _activeLow + ? value == PinValue.Low + : value == PinValue.High; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(DigitalInput)); + } + + public void Dispose() + { + if (_disposed) + return; + + if (_gpio.IsPinOpen(_pin)) + _gpio.ClosePin(_pin); + + _gpio.Dispose(); + _disposed = true; + } +} \ No newline at end of file diff --git a/csharp/Lib/Devices/GPIORaspberryPI4/GPIORaspberryPI4.csproj b/csharp/Lib/Devices/GPIORaspberryPI4/GPIORaspberryPI4.csproj new file mode 100644 index 000000000..9b12551de --- /dev/null +++ b/csharp/Lib/Devices/GPIORaspberryPI4/GPIORaspberryPI4.csproj @@ -0,0 +1,13 @@ + + + + net6.0 + enable + enable + + + + + + + diff --git a/csharp/Lib/Devices/GPIORaspberryPI4/IDigitalInput.cs b/csharp/Lib/Devices/GPIORaspberryPI4/IDigitalInput.cs new file mode 100644 index 000000000..5102bfae2 --- /dev/null +++ b/csharp/Lib/Devices/GPIORaspberryPI4/IDigitalInput.cs @@ -0,0 +1,8 @@ +namespace GPIORaspberryPI4; + +public interface IDigitalInput : IDisposable +{ + int Pin { get; } + bool IsActive { get; } + bool Read(); +} \ No newline at end of file diff --git a/csharp/Lib/Devices/GPIORaspberryPI4/IRelayOutput.cs b/csharp/Lib/Devices/GPIORaspberryPI4/IRelayOutput.cs new file mode 100644 index 000000000..351919cda --- /dev/null +++ b/csharp/Lib/Devices/GPIORaspberryPI4/IRelayOutput.cs @@ -0,0 +1,9 @@ +namespace GPIORaspberryPI4; + +public interface IRelayOutput : IDisposable +{ + void On(); + void Off(); + void Set(bool on); + bool IsOn { get; } +} \ No newline at end of file diff --git a/csharp/Lib/Devices/GPIORaspberryPI4/RelayOutput.cs b/csharp/Lib/Devices/GPIORaspberryPI4/RelayOutput.cs new file mode 100644 index 000000000..e0c1e2b65 --- /dev/null +++ b/csharp/Lib/Devices/GPIORaspberryPI4/RelayOutput.cs @@ -0,0 +1,81 @@ +using System.Device.Gpio; + +namespace GPIORaspberryPI4; + +public sealed class RelayOutput : IRelayOutput +{ + private readonly GpioController _gpio; + private readonly int _pin; + private readonly bool _activeLow; + private bool _disposed; + + public bool IsOn { get; private set; } + + public RelayOutput(int pin, bool activeLow = false) + { + _pin = pin; + _activeLow = activeLow; + + _gpio = new GpioController(); + _gpio.OpenPin(_pin, PinMode.Output); + + // Safe default state + WriteInternal(false); + } + + public void On() + { + ThrowIfDisposed(); + WriteInternal(true); + } + + public void Off() + { + ThrowIfDisposed(); + WriteInternal(false); + } + + public void Set(bool on) + { + ThrowIfDisposed(); + WriteInternal(on); + } + + private void WriteInternal(bool on) + { + var pinValue = _activeLow + ? (on ? PinValue.Low : PinValue.High) + : (on ? PinValue.High : PinValue.Low); + + _gpio.Write(_pin, pinValue); + IsOn = on; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(RelayOutput)); + } + + public void Dispose() + { + if (_disposed) + return; + + try + { + // Fail-safe: relay OFF on dispose + WriteInternal(false); + } + catch + { + // Ignore cleanup errors + } + + if (_gpio.IsPinOpen(_pin)) + _gpio.ClosePin(_pin); + + _gpio.Dispose(); + _disposed = true; + } +} \ No newline at end of file From a137ce67f896fa546c93f8bb73c594fecac268aa Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:17:46 +0200 Subject: [PATCH 03/22] Main program IO project created --- csharp/App/GpioTestingProject/Program.cs | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 csharp/App/GpioTestingProject/Program.cs diff --git a/csharp/App/GpioTestingProject/Program.cs b/csharp/App/GpioTestingProject/Program.cs new file mode 100644 index 000000000..aba8600a8 --- /dev/null +++ b/csharp/App/GpioTestingProject/Program.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using GPIORaspberryPI4; + +namespace InnovEnergy.App.GpioTestingProject; + +static class Program +{ + static void Main(string[] args) + { + Console.WriteLine("GPIO17 Relay Test Starting..."); + + using IRelayOutput relay = new RelayOutput(pin: 27, activeLow: false); + using IDigitalInput input = new DigitalInput(pin: 17, pullUp: true, activeLow: true); + + try + { + while (true) + { + if (input.Read()) + { + relay.On(); + Console.Write("\rInput ACTIVE -> Relay ON "); + } + else + { + relay.Off(); + Console.Write("\rInput INACTIVE -> Relay OFF "); + } + + Thread.Sleep(100); + } + } + catch (Exception ex) + { + Console.WriteLine($"ERROR: {ex.Message}"); + } + } +} \ No newline at end of file From 1e12bc708eeb624dfa73a7761368482d141e266b Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:18:44 +0200 Subject: [PATCH 04/22] update to 8 inverter in the config file --- csharp/App/SinexcelCommunication/Program.cs | 38 +++++++++++-------- .../SystemConfig/Config.cs | 10 +++-- .../SystemConfig/DeviceConfig.cs | 4 ++ 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/csharp/App/SinexcelCommunication/Program.cs b/csharp/App/SinexcelCommunication/Program.cs index b7ca31ada..ffbccabad 100644 --- a/csharp/App/SinexcelCommunication/Program.cs +++ b/csharp/App/SinexcelCommunication/Program.cs @@ -79,7 +79,11 @@ internal static class Program CreateChannel(d.Inverter1), CreateChannel(d.Inverter2), CreateChannel(d.Inverter3), - CreateChannel(d.Inverter4) + CreateChannel(d.Inverter4), + CreateChannel(d.Inverter5), + CreateChannel(d.Inverter6), + CreateChannel(d.Inverter7), + CreateChannel(d.Inverter8) }; @@ -112,9 +116,12 @@ internal static class Program StatusRecord? ReadStatus() { var config = Config.Load(); - var listOfInverterRecord = devices - .Select(device => device.Read()) - .ToList(); + + var readTasks = devices + .Select(device => Task.Run(() => device.Read())) + .ToArray(); + + var listOfInverterRecord = Task.WhenAll(readTasks).GetAwaiter().GetResult().ToList(); InverterRecords? inverterRecords = InverterRecords.FromInverters(listOfInverterRecord); @@ -212,11 +219,7 @@ internal static class Program // 1) Finalize previous hour if hour changed var hourlyData = EnergyAggregation.ProcessHourlyData(statusRecord, now); - /*if (hourlyData != null) - { - AggregatedDataFileWriter.AppendHourlyData(hourlyData, baseFolder); - }*/ - if (hourlyData != null) + if (hourlyData != null) { AggregatedDataFileWriter.AppendHourlyData(hourlyData, baseFolder); @@ -313,6 +316,7 @@ internal static class Program { if (statusrecord?.InverterRecord?.Devices == null) return; + // Compute once (same for all inverters) var config = statusrecord.Config; @@ -324,8 +328,11 @@ internal static class Program foreach (var inverter in statusrecord.InverterRecord.Devices) { // constants for every inverter - inverter.Battery1BackupSoc = (float)config.MinSoc; - inverter.Battery2BackupSoc = (float)config.MinSoc; + inverter.Battery1BackupSoc = (Single)config.MinSoc; + inverter.Battery2BackupSoc = (Single)config.MinSoc; + + inverter.Battery1MinSoc = 5; + inverter.Battery2MinSoc = 5; inverter.RepetitiveWeeks = SinexcelWeekDays.All; var operatingMode = config.OperatingPriority switch @@ -466,7 +473,7 @@ internal static class Program var returnedStatus = new StatusMessage { InstallationId = installationId, - Product = 2, + Product = 2, // 2 for Sodistorehome amd 5 for Sodistorepro Status = _sodiohomeAlarmState, Type = MessageType.AlarmOrWarning, Alarms = alarmList, @@ -653,16 +660,15 @@ internal static class Program 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 result5 = ConvertToModbusRegisters((status.InverterRecord.AvgBatterySoh.Value * 100), "UInt16", 31010); 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); diff --git a/csharp/App/SinexcelCommunication/SystemConfig/Config.cs b/csharp/App/SinexcelCommunication/SystemConfig/Config.cs index 0c5a7fffa..5c6dfe708 100644 --- a/csharp/App/SinexcelCommunication/SystemConfig/Config.cs +++ b/csharp/App/SinexcelCommunication/SystemConfig/Config.cs @@ -48,9 +48,13 @@ public class Config { Serial = new() {BaudRate = 115200, Parity = 0, StopBits = 1, DataBits = 8}, Inverter1 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB0", SlaveId = 1}, - Inverter2 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB1", SlaveId = 1}, - Inverter3 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB3", SlaveId = 1}, - Inverter4 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB4", SlaveId = 1}, + Inverter2 = new() {DeviceState = DeviceState.Disabled, Port = "/dev/ttyUSB1", SlaveId = 1}, + Inverter3 = new() {DeviceState = DeviceState.Disabled, Port = "/dev/ttyUSB2", SlaveId = 1}, + Inverter4 = new() {DeviceState = DeviceState.Disabled, Port = "/dev/ttyUSB3", SlaveId = 1}, + Inverter5 = new() {DeviceState = DeviceState.Disabled, Port = "/dev/ttyUSB5", SlaveId = 1}, + Inverter6 = new() {DeviceState = DeviceState.Disabled, Port = "/dev/ttyUSB6", SlaveId = 1}, + Inverter7 = new() {DeviceState = DeviceState.Disabled, Port = "/dev/ttyUSB7", SlaveId = 1}, + Inverter8 = new() {DeviceState = DeviceState.Disabled, Port = "/dev/ttyUSB8", SlaveId = 1}, }, //DynamicPricingEnabled = false, //DynamicPricingMode = DynamicPricingMode.Disabled, diff --git a/csharp/App/SinexcelCommunication/SystemConfig/DeviceConfig.cs b/csharp/App/SinexcelCommunication/SystemConfig/DeviceConfig.cs index e82c5f847..db377ce98 100644 --- a/csharp/App/SinexcelCommunication/SystemConfig/DeviceConfig.cs +++ b/csharp/App/SinexcelCommunication/SystemConfig/DeviceConfig.cs @@ -8,4 +8,8 @@ public record DeviceConfig public required SodiDevice Inverter2 { get; init; } public required SodiDevice Inverter3 { get; init; } public required SodiDevice Inverter4 { get; init; } + public required SodiDevice Inverter5 { get; init; } + public required SodiDevice Inverter6 { get; init; } + public required SodiDevice Inverter7 { get; init; } + public required SodiDevice Inverter8 { get; init; } } \ No newline at end of file From 11e85f304a81cbd4a595fac6aa78b1ce6c2c302e Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:19:18 +0200 Subject: [PATCH 05/22] deploy script for IO project to deploy and test --- csharp/App/GpioTestingProject/deploy.sh | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100755 csharp/App/GpioTestingProject/deploy.sh diff --git a/csharp/App/GpioTestingProject/deploy.sh b/csharp/App/GpioTestingProject/deploy.sh new file mode 100755 index 000000000..3208a0c80 --- /dev/null +++ b/csharp/App/GpioTestingProject/deploy.sh @@ -0,0 +1,27 @@ +#!/bin/bash +dotnet_version='net6.0' +salimax_ip="$1" +is_release="$2" # Pass --release if this is a real release +username='inesco' +root_password='Sodistore0918425' + + DOTNET="/snap/dotnet-sdk_60/current/dotnet" + + +set -e + +echo -e "\n============================ Build ============================\n" + +"$DOTNET" publish \ + ./GpioTestingProject.csproj \ + -p:PublishTrimmed=false \ + -c Release \ + -r linux-arm64 + +echo -e "\n============================ Deploy ============================\n" + +rsync -v \ + --exclude '*.pdb' \ + ./bin/Release/$dotnet_version/linux-arm64/publish/* \ + $username@"$salimax_ip":~/ + \ No newline at end of file From 0e0e23e401a9c039595a656c0dcd4b3d7459dd69 Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:19:37 +0200 Subject: [PATCH 06/22] Dynamic pricsing Classes created --- .../DataTypes/DynamicPricingMode.cs | 8 + .../ESS/DynamicPriceDataType.cs | 10 + .../ESS/DynamicPricingEngine.cs | 203 ++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 csharp/App/SinexcelCommunication/DataTypes/DynamicPricingMode.cs create mode 100644 csharp/App/SinexcelCommunication/ESS/DynamicPriceDataType.cs create mode 100644 csharp/App/SinexcelCommunication/ESS/DynamicPricingEngine.cs diff --git a/csharp/App/SinexcelCommunication/DataTypes/DynamicPricingMode.cs b/csharp/App/SinexcelCommunication/DataTypes/DynamicPricingMode.cs new file mode 100644 index 000000000..2391f93b4 --- /dev/null +++ b/csharp/App/SinexcelCommunication/DataTypes/DynamicPricingMode.cs @@ -0,0 +1,8 @@ +namespace InnovEnergy.App.SinexcelCommunication.DataTypes; + +public enum DynamicPricingMode +{ + Disabled, + Tou, + SpotPrice +} \ No newline at end of file diff --git a/csharp/App/SinexcelCommunication/ESS/DynamicPriceDataType.cs b/csharp/App/SinexcelCommunication/ESS/DynamicPriceDataType.cs new file mode 100644 index 000000000..bb2638b7e --- /dev/null +++ b/csharp/App/SinexcelCommunication/ESS/DynamicPriceDataType.cs @@ -0,0 +1,10 @@ +namespace InnovEnergy.App.SinexcelCommunication.ESS; + +internal enum DesiredAction +{ + OptimizeSelfUse, + Charge, + Discharge +} + + diff --git a/csharp/App/SinexcelCommunication/ESS/DynamicPricingEngine.cs b/csharp/App/SinexcelCommunication/ESS/DynamicPricingEngine.cs new file mode 100644 index 000000000..52e95809b --- /dev/null +++ b/csharp/App/SinexcelCommunication/ESS/DynamicPricingEngine.cs @@ -0,0 +1,203 @@ +using System; +using InnovEnergy.App.SinexcelCommunication.DataTypes; +using InnovEnergy.App.SinexcelCommunication.SystemConfig; +using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType; + +namespace InnovEnergy.App.SinexcelCommunication.ESS; +/* +public static class DynamicPricingEngine + { + + public static readonly TimeSpan CheapStart = TimeSpan.FromHours(22); // 22:00 + public static readonly TimeSpan CheapEnd = TimeSpan.FromHours(6); // 06:00 + + // Expensive (High tariff) + public static readonly TimeSpan HighStart = TimeSpan.FromHours(17); // 17:00 + public static readonly TimeSpan HighEnd = TimeSpan.FromHours(21); // 21:00 + + /// + /// Call this from your main loop. It sets statusrecord.Mode. + /// + /// liveSpotPrice is only needed when DynamicPricingMode == SpotPrice. + /// If your inverter cannot directly force Charge/Discharge, set inverterSupportsDirectForce=false + /// and it will execute via TimeChargeDischarge + rolling short time window. + /// + public static void Apply( + DateTime nowLocal, + Decimal? liveSpotPrice, + StatusRecord statusrecord, + Boolean inverterSupportsDirectForce, + Int32 rollingWindowMinutes = 10) + { + if (statusrecord == null) throw new ArgumentNullException(nameof(statusrecord)); + if (statusrecord.Config == null) throw new ArgumentNullException(nameof(statusrecord.Config)); + + var c = statusrecord.Config; + + // 0) Manual override (optional) + if (!c.DynamicPricingEnabled) + { + Console.WriteLine(" Dynamic pricing is disabled"); + return; + } + + if (statusrecord.InverterRecord.OperatingPriority == OperatingPriority.ModeNotSynched) + { + Console.WriteLine(" Inverter mode are not synched "); + return; + } +/* + + } + + // 1) Base operating mode: explicit modes ignore dynamic pricing + if (c.OperatingPriority == OperatingPriority.GridPriority) + { + SetMode(statusrecord, OperatingPriority.GridPriority); + return; + } + + if (c.OperatingPriority == OperatingPriority.BatteryPriority) + { + SetMode(statusrecord, OperatingPriority.BatteryPriority); + return; + } + + // 2) OperatingMode == OptimizeSelfUse -> dynamic pricing can apply + var desired = DecideDesiredAction(nowLocal, liveSpotPrice, c); + + if (desired == DesiredAction.OptimizeSelfUse) + { + SetMode(statusrecord, OperatingPriority.LoadPriority); + return; + } + + // 3) Execute desired action + if (inverterSupportsDirectForce) + { + statusrecord.Mode = desired == DesiredAction.Charge + ? BatteryMode.Charge + : BatteryMode.Discharge; + return; + } + + // 4) Inverter limitation: execute via TimeChargeDischarge rolling window + statusrecord.Mode = BatteryMode.TimeChargeDischarge; + + var (start, end) = MakeRollingWindow(nowLocal, rollingWindowMinutes); + + if (desired == DesiredAction.Charge) + { + c.TimeChargeStart = start; + c.TimeChargeEnd = end; + + // clear discharge window to avoid overlap + c.TimeDischargeStart = TimeSpan.Zero; + c.TimeDischargeEnd = TimeSpan.Zero; + } + else // Discharge + { + c.TimeDischargeStart = start; + c.TimeDischargeEnd = end; + + // clear charge window to avoid overlap + c.TimeChargeStart = TimeSpan.Zero; + c.TimeChargeEnd = TimeSpan.Zero; + } + } + + + private static void SetMode( StatusRecord statusrecord, OperatingPriority o) + { + var operatingMode = o switch + { + OperatingPriority.LoadPriority => WorkingMode.SpontaneousSelfUse, + OperatingPriority.BatteryPriority => WorkingMode.TimeChargeDischarge, + OperatingPriority.GridPriority => WorkingMode.PrioritySellElectricity, + _ => WorkingMode.SpontaneousSelfUse + }; + + if (statusrecord.InverterRecord.OperatingPriority != OperatingPriority.ModeNotSynched) + { + foreach (var inv in statusrecord?.InverterRecord.Devices) + { + inv.WorkingMode = operatingMode; + } + } + else + { + foreach (var inv in statusrecord?.InverterRecord.Devices) + { + Console.WriteLine(" Inverter mode are not synched"); + inv.WorkingMode = WorkingMode.SpontaneousSelfUse; + } + } + return; + } + + // ---------------------------- + // Decision logic + // ---------------------------- + private static DesiredAction DecideDesiredAction(DateTime nowLocal, decimal? liveSpotPrice, Config c) + { + if (c.DynamicPricingMode == DynamicPricingMode.Disabled) + return DesiredAction.OptimizeSelfUse; + + TimeSpan now = nowLocal.TimeOfDay; + + if (c.DynamicPricingMode == DynamicPricingMode.Tou) + { + bool isCheap = IsInTimeWindow(now,CheapStart, CheapEnd); + bool isHigh = IsInTimeWindow(now, HighStart, HighEnd); + + // Priority: cheap -> charge, then high -> discharge + if (isCheap) return DesiredAction.Charge; + if (isHigh) return DesiredAction.Discharge; + return DesiredAction.OptimizeSelfUse; + } + + if (c.DynamicPricingMode == DynamicPricingMode.SpotPrice) + { + if (!liveSpotPrice.HasValue) + return DesiredAction.OptimizeSelfUse; // safe fallback + + if (c.CheapPrice >= c.HighPrice) + throw new ArgumentException("Config error: CheapPrice must be lower than HighPrice."); + + decimal p = liveSpotPrice.Value; + + if (p <= c.CheapPrice) return DesiredAction.Charge; + if (p >= c.HighPrice) return DesiredAction.Discharge; + } + + return DesiredAction.OptimizeSelfUse; + } + + // ---------------------------- + // Helpers + // ---------------------------- + /// + /// [start, end) window, supports overnight. start==end means disabled. + /// + private static Boolean IsInTimeWindow(TimeSpan now, TimeSpan start, TimeSpan end) + { + if (start == end) return false; + + // Same-day + if (start < end) + return now >= start && now < end; + + // Overnight + return now >= start || now < end; + } + + private static (TimeSpan start, TimeSpan end) MakeRollingWindow(DateTime nowLocal, int minutes) + { + if (minutes <= 0) throw new ArgumentOutOfRangeException(nameof(minutes)); + + var start = nowLocal.TimeOfDay; + var end = (nowLocal + TimeSpan.FromMinutes(minutes)).TimeOfDay; + return (start, end); + } + } +}*/ \ No newline at end of file From d34b226b6e9e738181415ee6e86d6a14eddec880 Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:20:59 +0200 Subject: [PATCH 07/22] Kaco project updated states and register number --- csharp/App/KacoCommunication/Program.cs | 42 ++-- .../KacoCommunication/System/Controller.cs | 196 +----------------- csharp/Lib/Devices/Kaco92L3/KacoRecord.Api.cs | 3 +- .../Lib/Devices/Kaco92L3/KacoRecord.modbus.cs | 47 ++--- 4 files changed, 44 insertions(+), 244 deletions(-) diff --git a/csharp/App/KacoCommunication/Program.cs b/csharp/App/KacoCommunication/Program.cs index 6f9c3b956..ba74d9246 100644 --- a/csharp/App/KacoCommunication/Program.cs +++ b/csharp/App/KacoCommunication/Program.cs @@ -142,11 +142,11 @@ internal static class Program return new StatusRecord { - InverterRecord = kacoRecord, - GridMeterRecord = gridRecord, - DcDc = dcDcRecord, + InverterRecord = kacoRecord, + GridMeterRecord = gridRecord, + DcDc = dcDcRecord, ListOfBatteriesRecord = listOfBatteriesRecord, - StateMachine = StateMachine.Default, + StateMachine = StateMachine.Default, Config = config // load from disk every iteration, so config can be changed while running }; @@ -179,7 +179,7 @@ internal static class Program // the order matter of the next three lines var statusrecord = ReadStatus(); statusrecord?.CreateSimpleTopologyTextBlock().WriteLine(); - + statusrecord?.StateMachine.State.WriteLine(" state"); statusrecord?.StateMachine.Message.WriteLine(" Message"); @@ -194,6 +194,7 @@ internal static class Program Console.WriteLine(" ********************************* Kaco Inverter *********************************"); statusrecord?.InverterRecord?.ActivePowerW.WriteLine(" Inverter Power"); + statusrecord?.InverterRecord?.CurrentState.WriteLine(" CurrentState"); statusrecord?.InverterRecord?.RequestedState.WriteLine(" RequestedState"); statusrecord?.InverterRecord?.PcuError.WriteLine(" PcuError"); @@ -206,31 +207,20 @@ internal static class Program statusrecord?.InverterRecord?.MaxChargeVoltage.WriteLine(" MaxChargeVoltage"); statusrecord?.InverterRecord?.MaxChargeCurrent.WriteLine(" MaxChargeCurrent"); statusrecord?.InverterRecord?.ChargeCutoffCurrent.WriteLine(" ChargeCutoffCurrent"); - statusrecord?.InverterRecord?.ActivePowerSetPercent.WriteLine( "ActivePowerSetPercent"); + statusrecord?.InverterRecord?.ActivePowerSetPercent.WriteLine( "ActivePowerSetPercent"); statusrecord?.ControlSystemState(); - var i = 0; - foreach (var d in statusrecord.DcDc.Devices) - { - i++; - Console.WriteLine("before DcDc is " + i + d.Control.PowerStageEnable); - d.Control.ResetAlarmsAndWarnings = true; - d.Control.PowerStageEnable = true; - } - - - statusrecord?.DcDc?.SystemControl.ApplyDcDcDefaultSettings(); + //Maybe Introduce a condition to run it only when it's not in a runing mode InitializeKacoStartup(statusrecord); - foreach (var d in statusrecord.DcDc.Devices) - { - Console.WriteLine("After DcDc is " + d.Control.PowerStageEnable); - } - + Console.WriteLine(" ************************************ We are writing ************************************"); statusrecord?.Config.Save(); // save the config file - if (statusrecord?.InverterRecord != null) kacoDevice.Write(statusrecord.InverterRecord); + + kacoDevice.Write(statusrecord.InverterRecord); + dcDcDevices.Write(statusrecord.DcDc); + return statusrecord; } @@ -578,6 +568,10 @@ internal static class Program statusRecord?.DcDc?.Devices .Select(d => d.Control ) .ForAll(c => c.ControlMode = DcControlMode.VoltageDroop); + + //Add the DcDc configuration + statusRecord?.DcDc?.SystemControl.ApplyDcDcDefaultSettings(); + // // // 2. Send valid battery limits (Model 64202) // All values temporarily set to "1" as requested. @@ -659,7 +653,7 @@ internal static class Program sc.SlaveErrorHandling = SlaveErrorHandling.Relaxed; sc.SubSlaveErrorHandling = SubSlaveErrorHandling.Off; sc.TargetSlave = 0; - sc.ResetAlarmsAndWarnings = true; + sc.ResetAlarmsAndWarnings = true; // is this enough or shoud reset in each device } private static void InsertIntoJson(Dictionary jsonDict, String[] keys, String value) diff --git a/csharp/App/KacoCommunication/System/Controller.cs b/csharp/App/KacoCommunication/System/Controller.cs index ef14e4eb7..0e002a6ca 100644 --- a/csharp/App/KacoCommunication/System/Controller.cs +++ b/csharp/App/KacoCommunication/System/Controller.cs @@ -135,8 +135,10 @@ public static class KacoCurrentStateController private static bool State_Throttled(StatusRecord s) { + s.StateMachine.Message = "THROTTLED: still running. Power writes allowed."; s.InverterRecord.RequestedState = ReuqestedState.GridConnected; + s.InverterRecord.ActivePowerSetPercent = s.Config.ActivePowerPercent; // Power writes allowed here too return true; @@ -180,197 +182,3 @@ public static class KacoCurrentStateController return false; } } - - -/* - -public static class Controller -{ - private static UInt16 GetSystemState(this StatusRecord r) - { - if (r.InverterRecord != null) - { - return (UInt16)r.InverterRecord.CurrentState; - } - else - { - return (UInt16)StateMachine.Default.State; - } - - } - - public static Boolean ControlSystemState(this StatusRecord s) - { - s.StateMachine.State = s.GetSystemState(); - - var cs = s.InverterRecord?.CurrentState; // 64201.CurrentState (1..12) - s.StateMachine.State = (UInt16)cs; - - return s.StateMachine.State switch - { - 1 => State_Off(s), - 2 => State_Sleeping(s), - 3 => State_Starting(s), - 4 => State_Mppt(s), - 5 => State_Throttled(s), - 6 => State_ShuttingDown(s), - 7 => State_Fault(s), - 8 => State_Standby(s), - 9 => State_Precharge(s), - 10 => State_GridPreConnected(s), - 11 => State_GridConnected(s), - 12 => State_NoErrorPending(s), - _ => UnknownState(s) - }; - } - - // ───────────────────────────────────────────── - // Global rule: only allow power writes in 11 or 5 - // ───────────────────────────────────────────── - private static void EnforcePowerRules(StatusRecord s) - { - var cs = s.InverterRecord?.CurrentState; - - // must be 0 outside (11) or (5) - s.InverterRecord.ActivePowerSetPercent = 0f; - s.InverterRecord.ReactivePowerSetPercent = 0f; } - } - - // ───────────────────────────────────────────── - // State handlers (based purely on CurrentState) - // ───────────────────────────────────────────── - - private static Boolean State_Off(StatusRecord s) - { - s.StateMachine.Message = "OFF: write limits (once) and request connect (11)."; - EnforcePowerRules(s); - - // Write limits (ignore details) - s.InverterRecord.WriteLimits(); - - // Always aim for running - s.InverterRecord.RequestedState = ReuqestedState.GridConnected; - return false; - } - - - private static bool State_Sleeping(StatusRecord s) - { - s.StateMachine.Message = "SLEEPING: write limits (once) and request connect (11)."; - EnforcePowerRules(s); - - s.InverterRecord.WriteLimits(); - s.InverterRecord.RequestedState = ReuqestedState.GridConnected; - return false; - } - - private static bool State_Standby(StatusRecord s) - { - s.StateMachine.Message = "STANDBY: write limits (once) and request connect (11)."; - EnforcePowerRules(s); - - s.InverterRecord.WriteLimits(); - s.InverterRecord.RequestedState = ReuqestedState.GridConnected; - return false; - } - - private static bool State_Mppt(StatusRecord s) - { - s.StateMachine.Message = "MPPT: keep requesting connect (11)."; - EnforcePowerRules(s); - - s.InverterRecord.RequestedState = ReuqestedState.GridConnected; - return false; - } - - private static bool State_Starting(StatusRecord s) - { - s.StateMachine.Message = "STARTING: keep requesting connect (11), wait for 10/11/5."; - EnforcePowerRules(s); - - s.InverterRecord.RequestedState = ReuqestedState.GridConnected; - return false; - } - - private static bool State_Precharge(StatusRecord s) - { - s.StateMachine.Message = "PRECHARGE: keep requesting connect (11), wait."; - EnforcePowerRules(s); - - s.InverterRecord.RequestedState = ReuqestedState.GridConnected; - return false; - } - - private static bool State_GridPreConnected(StatusRecord s) - { - s.StateMachine.Message = "GRID_PRE_CONNECTED: keep requesting connect (11), wait for 11/5."; - EnforcePowerRules(s); - - s.InverterRecord.RequestedState = ReuqestedState.GridConnected; - return false; - } - - private static bool State_GridConnected(StatusRecord s) - { - s.StateMachine.Message = "GRID_CONNECTED: running. Power writes allowed."; - - // Keep request latched - s.InverterRecord.RequestedState = ReuqestedState.GridConnected; - - // Here you may write power setpoints (your own targets) - // Example: - // s.InverterRecord.ControlMode = ControlModeEnum.RpcRemote; - // s.InverterRecord.ActivePowerSetPercent = s.Targets.ActivePowerPercent; - // s.InverterRecord.ReactivePowerSetPercent = s.Targets.ReactivePowerPercent; - - return true; // end goal reached - } - - private static bool State_Throttled(StatusRecord s) - { - s.StateMachine.Message = "THROTTLED: still running. Power writes allowed."; - s.InverterRecord.RequestedState = ReuqestedState.GridConnected; - - // Power writes allowed here too - return true; - } - - private static bool State_ShuttingDown(StatusRecord s) - { - s.StateMachine.Message = "SHUTTING_DOWN: keep requesting connect (11); will reconnect after reaching 8/1."; - EnforcePowerRules(s); - - s.InverterRecord.RequestedState = ReuqestedState.GridConnected; - return false; - } - - private static bool State_Fault(StatusRecord s) - { - s.StateMachine.Message = "FAULT: power=0 and acknowledge with RequestedState=1 (OFF)."; - EnforcePowerRules(s); - - // Per doc: acknowledge uses RequestedState=1 - s.InverterRecord.RequestedState = ReuqestedState.Off; - return false; - } - - private static bool State_NoErrorPending(StatusRecord s) - { - s.StateMachine.Message = "NO_ERROR_PENDING: acknowledge with RequestedState=1 then controller will request 11 next cycles."; - EnforcePowerRules(s); - - // Per doc Step 8: set RequestedState to 1 to acknowledge - s.InverterRecord.RequestedState = ReuqestedState.Off; - return false; - } - - private static bool UnknownState(StatusRecord s) - { - s.StateMachine.Message = $"UNKNOWN CurrentState={s.InverterRecord.CurrentState}. For safety, power=0 and request 11."; - EnforcePowerRules(s); - - s.InverterRecord.RequestedState = ReuqestedState.GridConnected; - return false; - } - -}*/ \ No newline at end of file diff --git a/csharp/Lib/Devices/Kaco92L3/KacoRecord.Api.cs b/csharp/Lib/Devices/Kaco92L3/KacoRecord.Api.cs index 2118b7941..b4ce04063 100644 --- a/csharp/Lib/Devices/Kaco92L3/KacoRecord.Api.cs +++ b/csharp/Lib/Devices/Kaco92L3/KacoRecord.Api.cs @@ -175,9 +175,8 @@ public partial class KacoRecord /// Scale factor for battery currents (A_SF). /// public Int16 BatteryCurrentScaleFactor => _battCharASf; - + public Single ActivePowerW => ScaleSunspec( (Int16)(_activePowerW * -1), _wSf); // this to correct the sign to fit in our sign system - public Int16 ActivePowerW => _activePowerW; public Int16 ReactivePowerVar => _reactivePowerVar; public Int16 LineFrequencyHz => _lineFrequencyHz; diff --git a/csharp/Lib/Devices/Kaco92L3/KacoRecord.modbus.cs b/csharp/Lib/Devices/Kaco92L3/KacoRecord.modbus.cs index 16e382af1..2bf510f12 100644 --- a/csharp/Lib/Devices/Kaco92L3/KacoRecord.modbus.cs +++ b/csharp/Lib/Devices/Kaco92L3/KacoRecord.modbus.cs @@ -36,42 +36,41 @@ public partial class KacoRecord [HoldingRegister(41074)] private UInt16 _reserved13; // 0xA072 [HoldingRegister(41075)] private UInt16 _reserved14; // 0xA073 [HoldingRegister(41076)] private UInt16 _reserved15; // 0xA074 - [HoldingRegister(41077)] private UInt16 _reserved16; // 0xA075 // Status / error - [HoldingRegister(41078)] private UInt16 _stVnd; // 0xA076 – StVnd (enum16, R) PrologState - [HoldingRegister(41079)] private UInt16 _stPu; // 0xA077 – StPu (enum16, R) Power Unit State (DSP) - [HoldingRegister(41080)] private UInt16 _stPcu; // 0xA078 – StPcu (enum16, R) pcu state - [HoldingRegister(41081)] private UInt16 _errPcu; // 0xA079 – ErrPcu (enum16, R) pcu error + [HoldingRegister(41077)] private UInt16 _stVnd; // 0xA076 – StVnd (enum16, R) PrologState + [HoldingRegister(41078)] private UInt16 _stPu; // 0xA077 – StPu (enum16, R) Power Unit State (DSP) + [HoldingRegister(41079)] private UInt16 _stPcu; // 0xA078 – StPcu (enum16, R) pcu state + [HoldingRegister(41080)] private UInt16 _errPcu; // 0xA079 – ErrPcu (enum16, R) pcu error // Active power ramp parameters - [HoldingRegister(41082, writable: true)] private UInt16 _wParamRmpTms; // 0xA07A – WParamRmpTms (uint16, RW, s) - [HoldingRegister(41083, writable: true)] private UInt16 _wParamRmpDecTmn; // 0xA07B – WParamRmpDecTmn (uint16, RW, %ref/min) - [HoldingRegister(41084, writable: true)] private UInt16 _wParamRmpIncTmn; // 0xA07C – WParamRmpIncTmn (uint16, RW, %ref/min) + [HoldingRegister(41081, writable: true)] private UInt16 _wParamRmpTms; // 0xA07A – WParamRmpTms (uint16, RW, s) + [HoldingRegister(41082, writable: true)] private UInt16 _wParamRmpDecTmn; // 0xA07B – WParamRmpDecTmn (uint16, RW, %ref/min) + [HoldingRegister(41083, writable: true)] private UInt16 _wParamRmpIncTmn; // 0xA07C – WParamRmpIncTmn (uint16, RW, %ref/min) - [HoldingRegister(41085)] private UInt16 _reserved24; // 0xA07D – Reserved - [HoldingRegister(41086)] private UInt16 _reserved25; // 0xA07E – Reserved + [HoldingRegister(41084)] private UInt16 _reserved24; // 0xA07D – Reserved + [HoldingRegister(41085)] private UInt16 _reserved25; // 0xA07E – Reserved - [HoldingRegister(41087, writable: true)] private UInt16 _wParamEna; // 0xA07F – WParam_Ena (enum16, RW) WSet_Ena control 0 or 1 + [HoldingRegister(41086, writable: true)] private UInt16 _wParamEna; // 0xA07F – WParam_Ena (enum16, RW) WSet_Ena control 0 or 1 // Reactive power ramp parameters - [HoldingRegister(41088, writable: true)] private UInt16 _varParamRmpTms; // 0xA080 – VarParamRmpTms (uint16, RW, s) - [HoldingRegister(41089, writable: true)] private UInt16 _varParamRmpDecTmn; // 0xA081 – VarParamRmpDecTmn (uint16, RW, %ref/min) - [HoldingRegister(41090, writable: true)] private UInt16 _varParamRmpIncTmn; // 0xA082 – VarParamRmpIncTmn (uint16, RW, %ref/min) + [HoldingRegister(41087, writable: true)] private UInt16 _varParamRmpTms; // 0xA080 – VarParamRmpTms (uint16, RW, s) + [HoldingRegister(41088, writable: true)] private UInt16 _varParamRmpDecTmn; // 0xA081 – VarParamRmpDecTmn (uint16, RW, %ref/min) + [HoldingRegister(41089, writable: true)] private UInt16 _varParamRmpIncTmn; // 0xA082 – VarParamRmpIncTmn (uint16, RW, %ref/min) - [HoldingRegister(41091)] private UInt16 _reserved30; // 0xA083 – Reserved - [HoldingRegister(41092)] private UInt16 _reserved31; // 0xA084 – Reserved + [HoldingRegister(41090)] private UInt16 _reserved30; // 0xA083 – Reserved + [HoldingRegister(41091)] private UInt16 _reserved31; // 0xA084 – Reserved - [HoldingRegister(41093, writable: true)] private UInt16 _varParamEna; // 0xA085 – VarParam_Ena (enum16, RW) Enumerated valued. Percent limit VAr enable/disable control. + [HoldingRegister(41092, writable: true)] private UInt16 _varParamEna; // 0xA085 – VarParam_Ena (enum16, RW) Enumerated valued. Percent limit VAr enable/disable control. // Measurements (read-only) - [HoldingRegister(41094)] private UInt16 _phaseVoltageAN; // 0xA086 – PhVphA (uint16, R, V, V_SF) - [HoldingRegister(41095)] private UInt16 _phaseVoltageBN; // 0xA087 – PhVphB (uint16, R, V, V_SF) - [HoldingRegister(41096)] private UInt16 _phaseVoltageCN; // 0xA088 – PhVphC (uint16, R, V, V_SF) + [HoldingRegister(41093)] private UInt16 _phaseVoltageAN; // 0xA086 – PhVphA (uint16, R, V, V_SF) + [HoldingRegister(41094)] private UInt16 _phaseVoltageBN; // 0xA087 – PhVphB (uint16, R, V, V_SF) + [HoldingRegister(41095)] private UInt16 _phaseVoltageCN; // 0xA088 – PhVphC (uint16, R, V, V_SF) - [HoldingRegister (41097)] private Int16 _activePowerW; // 0xA089 – W (int16, R, W, W_SF) - [HoldingRegister (41098)] private Int16 _reactivePowerVar; // 0xA08A – VAR (int16, R, var, Var_SF) - [HoldingRegister (41099)] private Int16 _lineFrequencyHz; // 0xA08B – Hz (int16, R, Hz, Hz_SF) + [HoldingRegister (41096)] private Int16 _activePowerW; // 0xA089 – W (int16, R, W, W_SF) + [HoldingRegister (41097)] private Int16 _reactivePowerVar; // 0xA08A – VAR (int16, R, var, Var_SF) + [HoldingRegister (41098)] private Int16 _lineFrequencyHz; // 0xA08B – Hz (int16, R, Hz, Hz_SF) // Scale factors (SunSpec sunsf) // Scale factor for active power percent. @@ -82,7 +81,7 @@ public partial class KacoRecord [HoldingRegister(41109)] private Int16 _rmpTmsSf; // 0xA0F5 – RmpTms_SF // Scale factor for increment and decrement ramps. [HoldingRegister(41110)] private Int16 _rmpIncDecSf; // 0xA0F6 – RmpIncDec_SF - + [HoldingRegister(41112)] private Int16 _wSf; // W_SF // Header [HoldingRegister(41115)] private UInt16 _battCharId; // ID = 64202 [HoldingRegister(41116)] private UInt16 _battCharLength; // L = 6 + (RBCount * 8) From 9d4d87ce27bf74086e65885149308e0bcf05c880 Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:21:57 +0200 Subject: [PATCH 08/22] Addexport limitation Growatt and fix the udp check --- .../MiddlewareClasses/MiddlewareAgent.cs | 137 +++++++++++++----- csharp/App/GrowattCommunication/deploy.sh | 3 +- .../WITGrowatt4-15K/WITGrowatRecord.Api.cs | 14 ++ .../WITGrowatt4-15K/WITGrowatRecord.Modbus.cs | 6 +- 4 files changed, 124 insertions(+), 36 deletions(-) diff --git a/csharp/App/GrowattCommunication/MiddlewareClasses/MiddlewareAgent.cs b/csharp/App/GrowattCommunication/MiddlewareClasses/MiddlewareAgent.cs index 279e39052..84a6ef8fc 100644 --- a/csharp/App/GrowattCommunication/MiddlewareClasses/MiddlewareAgent.cs +++ b/csharp/App/GrowattCommunication/MiddlewareClasses/MiddlewareAgent.cs @@ -9,24 +9,41 @@ namespace InnovEnergy.App.GrowattCommunication.MiddlewareClasses; public static class MiddlewareAgent { - private static UdpClient _udpListener = null!; + private static UdpClient _udpListener = null!; private static IPAddress? _controllerIpAddress; - private static EndPoint? _endPoint; + private static EndPoint? _endPoint; - public static void InitializeCommunicationToMiddleware() + public static bool InitializeCommunicationToMiddleware() { - _controllerIpAddress = FindVpnIp(); - if (Equals(IPAddress.None, _controllerIpAddress)) + try { - Console.WriteLine("There is no VPN interface, exiting..."); + _controllerIpAddress = FindVpnIp(); + if (Equals(IPAddress.None, _controllerIpAddress)) + { + Console.WriteLine("There is no VPN interface."); + _udpListener = null; + return false; + } + + const int udpPort = 9000; + _endPoint = new IPEndPoint(_controllerIpAddress, udpPort); + + _udpListener?.Close(); + _udpListener?.Dispose(); + + _udpListener = new UdpClient(); + _udpListener.Client.Blocking = false; + _udpListener.Client.Bind(_endPoint); + + Console.WriteLine($"UDP listener bound to {_endPoint}"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Failed to initialize middleware communication: {ex}"); + _udpListener = null; + return false; } - - const Int32 udpPort = 9000; - _endPoint = new IPEndPoint(_controllerIpAddress, udpPort); - - _udpListener = new UdpClient(); - _udpListener.Client.Blocking = false; - _udpListener.Client.Bind(_endPoint); } private static IPAddress FindVpnIp() @@ -50,40 +67,92 @@ public static class MiddlewareAgent return IPAddress.None; } - public static Configuration? SetConfigurationFile() { - if (_udpListener.Available > 0) + try { + // Ensure listener is initialized + if (_udpListener == null) + { + Console.WriteLine("UDP listener not initialized, trying to initialize..."); + InitializeCommunicationToMiddleware(); + + if (_udpListener == null) + { + Console.WriteLine("Failed to initialize UDP listener."); + return null; + } + } + + // Check if data is available + if (_udpListener.Available <= 0) + return null; + IPEndPoint? serverEndpoint = null; - - var replyMessage = "ACK"; - var replyData = Encoding.UTF8.GetBytes(replyMessage); - + var udpMessage = _udpListener.Receive(ref serverEndpoint); - var message = Encoding.UTF8.GetString(udpMessage); - + var message = Encoding.UTF8.GetString(udpMessage); + + Console.WriteLine($"Received raw UDP message from {serverEndpoint}: {message}"); + var config = JsonSerializer.Deserialize(message); - + if (config != null) { - Console.WriteLine($"Received a configuration message: " + - "MinimumSoC is " + config.MinimumSoC + " and operating priorty is " +config.OperatingPriority + "Number of batteries is " + config.BatteriesCount - + "MaximumChargingCurrent is " + config.MaximumChargingCurrent + "MaximumDischargingCurrent " + config.MaximumDischargingCurrent + " Control permission is" + config.ControlPermission ); - - // Send the reply to the sender's endpoint + Console.WriteLine( + $"Received a configuration message:\n" + + $"MinimumSoC: {config.MinimumSoC}\n" + + $"OperatingPriority: {config.OperatingPriority}\n" + + $"Number of batteries: {config.BatteriesCount}\n" + + $"Maximum Charging current: {config.MaximumChargingCurrent}\n" + + $"Maximum Discharging current: {config.MaximumDischargingCurrent}\n" + + $"ControlPermission: {config.ControlPermission}" + ); + + // Send ACK + var replyMessage = "ACK"; + var replyData = Encoding.UTF8.GetBytes(replyMessage); + _udpListener.Send(replyData, replyData.Length, serverEndpoint); Console.WriteLine($"Replied to {serverEndpoint}: {replyMessage}"); + return config; } + else + { + Console.WriteLine("Received UDP message but failed to deserialize Configuration."); + return null; + } } - - if (_endPoint != null && !_endPoint.Equals(_udpListener.Client.LocalEndPoint as IPEndPoint)) + catch (SocketException ex) { - Console.WriteLine("UDP address has changed, rebinding..."); - InitializeCommunicationToMiddleware(); - } - return null; - } + Console.WriteLine($"Socket error in SetConfigurationFile: {ex}"); + + // Recover by reinitializing + try + { + _udpListener?.Close(); + _udpListener?.Dispose(); + } + catch + { + // ignored + } + _udpListener = null; + InitializeCommunicationToMiddleware(); + + return null; + } + catch (JsonException ex) + { + Console.WriteLine($"JSON deserialization error: {ex}"); + return null; + } + catch (Exception ex) + { + Console.WriteLine($"Unexpected error in SetConfigurationFile: {ex}"); + return null; + } + } } \ No newline at end of file diff --git a/csharp/App/GrowattCommunication/deploy.sh b/csharp/App/GrowattCommunication/deploy.sh index a919ab29c..05bd7293a 100755 --- a/csharp/App/GrowattCommunication/deploy.sh +++ b/csharp/App/GrowattCommunication/deploy.sh @@ -6,12 +6,13 @@ username='inesco' root_password='Sodistore0918425' release_flag_file="./bin/Release/$dotnet_version/linux-arm64/publish/.release.flag" +DOTNET="/snap/dotnet-sdk_60/current/dotnet" set -e echo -e "\n============================ Build ============================\n" -dotnet publish \ +"$DOTNET" publish \ ./GrowattCommunication.csproj \ -p:PublishTrimmed=false \ -c Release \ diff --git a/csharp/Lib/Devices/WITGrowatt4-15K/WITGrowatRecord.Api.cs b/csharp/Lib/Devices/WITGrowatt4-15K/WITGrowatRecord.Api.cs index 4d29fc83d..b5c1e7b0a 100644 --- a/csharp/Lib/Devices/WITGrowatt4-15K/WITGrowatRecord.Api.cs +++ b/csharp/Lib/Devices/WITGrowatt4-15K/WITGrowatRecord.Api.cs @@ -141,6 +141,20 @@ public partial class WITGrowatRecord //set => _PowerFactor = value; } + + + public UInt16 ExportLimitationEnabled + { + get => _ExportLimitationEnabled; + set => _ExportLimitationEnabled = value; + } + + + public Int16 ExportLimitationPowerRate + { + get => _ExportLimitationPowerRate; + set => _ExportLimitationPowerRate = value; + } public UInt16 EmsCommunicationFailureTime { diff --git a/csharp/Lib/Devices/WITGrowatt4-15K/WITGrowatRecord.Modbus.cs b/csharp/Lib/Devices/WITGrowatt4-15K/WITGrowatRecord.Modbus.cs index b31a63090..02f4f4329 100644 --- a/csharp/Lib/Devices/WITGrowatt4-15K/WITGrowatRecord.Modbus.cs +++ b/csharp/Lib/Devices/WITGrowatt4-15K/WITGrowatRecord.Modbus.cs @@ -165,7 +165,11 @@ public partial class WITGrowatRecord //[HoldingRegister(30152, writable: true)] private UInt16 _Reserved11; // [HoldingRegister(30154, writable: true)] private UInt16 _ActivePowerPercent; // Limit percentage: [0, 100]; Default: 100; takes the smaller value of 30151 and 30154 as actual active limit; Not stored - [HoldingRegister(30162)] private UInt16 _PowerFactor; // [0, 2000] ∪ [18000, 20000]; Default: 20000; Actual PF = (Register Value - 10000) + [HoldingRegister(30162)] private UInt16 _PowerFactor; // [0, 2000] ∪ [18000, 20000]; Default: 20000; Actual PF = (Register Value - 10000) + + [HoldingRegister(30200, writable : true)] private UInt16 _ExportLimitationEnabled; // // 0: not enabled // 1: single machine Export Limitation enable + [HoldingRegister(30201, writable : true)] private Int16 _ExportLimitationPowerRate; // [-100,100] // Default value: 0 Positive value is backflow, negative value is fair current + [HoldingRegister(30203, writable : true)] private UInt16 _EmsCommunicationFailureTime; // [1,300] TODO to 30 [HoldingRegister(30204, writable : true)] private Boolean _EnableEmsCommunicationFailureTime; // 0: disabled, 1 = enabled we should not enable this the naming is not correct [HoldingRegister(30300)] private UInt16 _BatteryClusterIndex; // [0..3] From 94c9a7b28f91d9aafbe6c11aff75fa7d2e0e2ba3 Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:23:09 +0200 Subject: [PATCH 09/22] Create a tunnel script to connect different device on the kaco installation --- csharp/App/KacoCommunication/tunnelSalimax.sh | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100755 csharp/App/KacoCommunication/tunnelSalimax.sh diff --git a/csharp/App/KacoCommunication/tunnelSalimax.sh b/csharp/App/KacoCommunication/tunnelSalimax.sh new file mode 100755 index 000000000..8a6323ce0 --- /dev/null +++ b/csharp/App/KacoCommunication/tunnelSalimax.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +host="ie-entwicklung@$1" + +tunnel() { + name=$1 + ip=$2 + rPort=$3 + lPort=$4 + + echo -n "$name @ $ip mapped to localhost:$lPort " + ssh -nNTL "$lPort:$ip:$rPort" "$host" 2> /dev/null & + + until nc -vz 127.0.0.1 $lPort 2> /dev/null + do + echo -n . + sleep 0.5 + done + + echo "ok" +} + +echo "" + +tunnel "Trumpf DCDC (http) " 10.0.2.1 80 8002 +tunnel "Kaco Inverter (http) " 10.0.3.1 80 8003 +tunnel "Int Emu Meter (http) " 10.0.4.2 80 8004 + +echo +echo "press any key to close the tunnels ..." +read -r -n 1 -s +kill $(jobs -p) +echo "done" + From b7742ac9374439f4580d0adfff5c1917d4b60204 Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:23:26 +0200 Subject: [PATCH 10/22] create and update toplogy for kaco installation --- csharp/App/KacoCommunication/Topology.cs | 93 ++++++++++++------------ 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/csharp/App/KacoCommunication/Topology.cs b/csharp/App/KacoCommunication/Topology.cs index d1a952e44..8f22e8225 100644 --- a/csharp/App/KacoCommunication/Topology.cs +++ b/csharp/App/KacoCommunication/Topology.cs @@ -22,14 +22,14 @@ namespace InnovEnergy.App.KacoCommunication; // │ -3205 W │<<<<<<<<<│ -6646 W │<<<<<<<<<<├────────┤>>>>>>>>>>├───────┤>>>>>>>>>>│ 490 mA │ │ 490 mA │ │ 490 mA │ // │ -3507 W │ (a) │ -5071 W │ (h) │ 776 V │ (k) │ 56 V │ (l) │ 250 °C │ │ 250 °C │ │ 250 °C │ // │ -3605 W │ └─────────┘ └────────┘ └───────┘ │ 445 A │ │ 445 A │ │ 445 A │ -// └─────────┘ V │ │ │ │ │ │ -// V │ │ │ │ │ │ -// (j) 0 W └────────────┘ └────────────┘ └────────────┘ -// V -// V -// ┌──────┐ -// │ Load │ -// └──────┘ +// └─────────┘ V │ │ │ │ │ │ +// V │ │ │ │ │ │ +// (j) 0 W └────────────┘ └────────────┘ └────────────┘ +// V +// V +// ┌──────┐ +// │ Load │ +// └──────┘ // @@ -44,7 +44,7 @@ public static class SimpleTopology var h = status.InverterRecord?.ActivePowerW; var i = 0; - var j = 0; + var j = h - a; var k = status.DcDc?.Dc.Battery.Power.Value; @@ -53,8 +53,8 @@ public static class SimpleTopology var l = status.ListOfBatteriesRecord?.Sum(r => r.Power); var grid = status.CreateGridColumn(a); - var acdc = status.CreateAcDcColumn(h); - var dcBus = status.CreateDcBusColumn(i, j, k); + var acdc = status.CreateAcDcColumn(h,j); + var dcBus = status.CreateDcBusColumn( k); var dcdc = status.CreateDcDcColumn(l); var batteries = status.CreateBatteriesRow(); @@ -94,7 +94,7 @@ public static class SimpleTopology return TextBlock.AlignCenterVertical(gridBox, flow); } - private static TextBlock CreateAcDcColumn(this StatusRecord status, ActivePower? h) + private static TextBlock CreateAcDcColumn(this StatusRecord status, ActivePower? h, ActivePower? j) { // ┌─────────┐ // │ AC/DC │ @@ -102,33 +102,6 @@ public static class SimpleTopology // │ dev1 P │ // │ dev2 P │ // └─────────┘ (h) flow to DC Bus - - var acdcBox = TextBlock - .AlignLeft(status.InverterRecord?.ActivePowerW.ToString() ?? "???") - .TitleBox("AC/DC"); - - var flowToDcBus = Flow.Horizontal(h); - - return TextBlock.AlignCenterVertical(acdcBox, flowToDcBus); - } - - private static TextBlock CreateDcBusColumn( - this StatusRecord status, - ActivePower? i, - ActivePower? j, - ActivePower? k) - { - // ┌────┐ - // │ PV │ - // └────┘ - // V - // (i) 13.2 kW - // V - // ┌────────┐ (k) >>>>>>>>> to DC/DC - // │ Dc Bus │>>>>>>>>>>>>>>>>>>> - // ├────────┤ - // │ 776 V │ - // └────────┘ // V // (j) 0 W // V @@ -136,9 +109,42 @@ public static class SimpleTopology // │ Load │ // └──────┘ + var acdcBox = TextBlock + .AlignLeft(status.InverterRecord?.ActivePowerW.ToString() ?? "???") + .TitleBox("AC/DC"); + + ////////////// top ////////////// + ActivePower i = 0; + var pvBox = TextBlock.FromString("PV").Box(); + var pvFlow = Flow.Vertical(i); + + // Load box + vertical flow + var busToLoad = Flow.Vertical(j); + var loadBox = TextBlock.FromString("Load").Box(); + + var flowToDcBus = Flow.Horizontal(h); + + return TextBlock.AlignCenterVertical ( + TextBlock.AlignCenterHorizontal(pvBox, pvFlow, acdcBox, busToLoad, loadBox), + flowToDcBus + ); + } + + private static TextBlock CreateDcBusColumn( + this StatusRecord status, + ActivePower? k) + { + + // ┌────────┐ (k) >>>>>>>>> to DC/DC + // │ Dc Bus │>>>>>>>>>>>>>>>>>>> + // ├────────┤ + // │ 776 V │ + // └────────┘ + + // PV box + vertical flow - var pvBox = TextBlock.FromString("PV").Box(); - var pvToBus = Flow.Vertical(i); + // var pvBox = TextBlock.FromString("PV").Box(); + // var pvToBus = Flow.Vertical(i); // DC bus box (voltage from your DcDc record matches your existing code) var dcBusVoltage = status.DcDc.Dc.Link.Voltage.Value; @@ -150,13 +156,10 @@ public static class SimpleTopology // Horizontal flow from DC Bus to DC/DC var busToDcDc = Flow.Horizontal(k); - // Load box + vertical flow - var busToLoad = Flow.Vertical(j); - var loadBox = TextBlock.FromString("Load").Box(); // Assemble: put PV above DC Bus, Load below DC Bus, and the (k) flow beside the bus. return TextBlock.AlignCenterVertical( - TextBlock.AlignCenterHorizontal(pvBox, pvToBus, dcBusBox, busToLoad, loadBox), + TextBlock.AlignCenterHorizontal( dcBusBox), busToDcDc ); } From f8b9a4d5953c81993f7e72152851afadead331cc Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:23:53 +0200 Subject: [PATCH 11/22] update sync my release for growatt release --- csharp/App/GrowattCommunication/sync-myRelease.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csharp/App/GrowattCommunication/sync-myRelease.sh b/csharp/App/GrowattCommunication/sync-myRelease.sh index 1c80c3fed..c5d92ba4e 100755 --- a/csharp/App/GrowattCommunication/sync-myRelease.sh +++ b/csharp/App/GrowattCommunication/sync-myRelease.sh @@ -1,7 +1,7 @@ #!/bin/bash WATCHDIR="$HOME/sync/work/Code/CSharp/git_trunk/csharp/App/GrowattCommunication/bin/Release/net6.0/linux-arm64/publish" -DEST="ubuntu@91.92.155.224:/home/ubuntu/Releases" +DEST="ubuntu@91.92.155.224:/home/ubuntu/GrowattReleases" echo "👀 Watching for real releases in $WATCHDIR..." From 51b8dada6c0331a1e5d05ea98ad23ba1e9b792bf Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:24:33 +0200 Subject: [PATCH 12/22] Correct Sinexcel grid power sign and fix the numbering of few wrong register --- csharp/Sinexcel 12K TL/SinexcelRecord.Api.cs | 8 +++--- .../Sinexcel 12K TL/SinexcelRecord.Modbus.cs | 26 +++++++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/csharp/Sinexcel 12K TL/SinexcelRecord.Api.cs b/csharp/Sinexcel 12K TL/SinexcelRecord.Api.cs index 0826b112e..b71d30a21 100644 --- a/csharp/Sinexcel 12K TL/SinexcelRecord.Api.cs +++ b/csharp/Sinexcel 12K TL/SinexcelRecord.Api.cs @@ -195,6 +195,8 @@ public partial class SinexcelRecord private readonly Int16 _factorFromKwtoW = 1000; + private readonly Int16 _correctingSign = -1; + // ─────────────────────────────────────────────── // Public API — Decoded Float Values // ─────────────────────────────────────────────── @@ -375,7 +377,7 @@ public partial class SinexcelRecord public ActivePower TotalPhotovoltaicPower => ConvertBitPatternToFloat(_totalPhotovoltaicPower) * _factorFromKwtoW; public ActivePower TotalBatteryPower => ConvertBitPatternToFloat(_totalBatteryPower) * _factorFromKwtoW; public ActivePower TotalLoadPower => ConvertBitPatternToFloat(_totalLoadPower) * _factorFromKwtoW ; - public ActivePower TotalGridPower => ConvertBitPatternToFloat(_totalGridPower) * _factorFromKwtoW ; + public ActivePower TotalGridPower => ConvertBitPatternToFloat(_totalGridPower) * _factorFromKwtoW * _correctingSign ; // we correct sign public ActivePower ImportantLoadTotalPower => ConvertBitPatternToFloat(_importantLoadTotalPower)* _factorFromKwtoW; public ActivePower GeneralLoadTotalPower => ConvertBitPatternToFloat(_generalLoadTotalPower)* _factorFromKwtoW; public Voltage PvVoltage3 => ConvertBitPatternToFloat(_pv3Voltage); @@ -663,7 +665,7 @@ public partial class SinexcelRecord // ─────────────────────────────────────────────── // Parallel / System Settings // ─────────────────────────────────────────────── - public SinexcelMachineMode MachineMode + /* public SinexcelMachineMode MachineMode { get => (SinexcelMachineMode)ConvertBitPatternToFloat(_singleOrParallelMachine); set => _singleOrParallelMachine = (UInt32)value; @@ -686,7 +688,7 @@ public partial class SinexcelRecord get => (AccreditedCountry)(Int32)BitConverter.Int32BitsToSingle(unchecked((Int32)_accreditedCountries)); set => _accreditedCountries = BitConverter.ToUInt32(BitConverter.GetBytes((Single)value), 0); } - + */ // ─────────────────────────────────────────────── // Control Commands // ─────────────────────────────────────────────── diff --git a/csharp/Sinexcel 12K TL/SinexcelRecord.Modbus.cs b/csharp/Sinexcel 12K TL/SinexcelRecord.Modbus.cs index f94990f32..0318fb7c7 100644 --- a/csharp/Sinexcel 12K TL/SinexcelRecord.Modbus.cs +++ b/csharp/Sinexcel 12K TL/SinexcelRecord.Modbus.cs @@ -440,24 +440,24 @@ public partial class SinexcelRecord [HoldingRegister(12384, writable: true)] private UInt32 _outputVoltageAdjustmentFactor; // 0x3060 [HoldingRegister(12386, writable: true)] private UInt32 _setValueBatteryUndervoltage1; // 0x3062 [HoldingRegister(12388, writable: true)] private UInt32 _inverterPowerLimit; // 0x3064 - [HoldingRegister(12400, writable: true)] private UInt32 _battery2Capacity; // 0x30B0 - [HoldingRegister(12402, writable: true)] private UInt32 _maxChargingCurrentBattery2; // 0x30B2 - [HoldingRegister(12404, writable: true)] private UInt32 _maxDischargingCurrentBattery2; // 0x30B4 - [HoldingRegister(12406, writable: true)] private UInt32 _battery2RatedVoltage; // 0x30B6 - [HoldingRegister(12408, writable: true)] private UInt32 _battery2MinSoc; // 0x30B8 - [HoldingRegister(12410, writable: true)] private UInt32 _battery2OverVoltageSetting; // 0x30BA - [HoldingRegister(12412, writable: true)] private UInt32 _battery2UnderVoltageSetpoint; // 0x30BC + [HoldingRegister(12464, writable: true)] private UInt32 _battery2Capacity; // 0x30B0 + [HoldingRegister(12466, writable: true)] private UInt32 _maxChargingCurrentBattery2; // 0x30B2 + [HoldingRegister(12468, writable: true)] private UInt32 _maxDischargingCurrentBattery2; // 0x30B4 + [HoldingRegister(12470, writable: true)] private UInt32 _battery2RatedVoltage; // 0x30B6 + [HoldingRegister(12472, writable: true)] private UInt32 _battery2MinSoc; // 0x30B8 + [HoldingRegister(12474, writable: true)] private UInt32 _battery2OverVoltageSetting; // 0x30BA + [HoldingRegister(12476, writable: true)] private UInt32 _battery2UnderVoltageSetpoint; // 0x30BC // - [HoldingRegister(12414, writable: true)] private UInt32 _singleOrParallelMachine; // 0x30BE - [HoldingRegister(12416, writable: true)] private UInt32 _numberOfSystemModules; // 0x30C0 - [HoldingRegister(12418, writable: true)] private UInt32 _parallelModuleMachineNumber; // 0x30C2 - [HoldingRegister(12420, writable: true)] private UInt32 _accreditedCountries; // 0x30C4 + /* [HoldingRegister(12478, writable: true)] private UInt32 _singleOrParallelMachine; // 0x30BE // to be verified, writing not allowed + [HoldingRegister(12480, writable: true)] private UInt32 _numberOfSystemModules; // 0x30C0 // to be verified, writing not allowed + [HoldingRegister(12482, writable: true)] private UInt32 _parallelModuleMachineNumber; // 0x30C2 // to be verified, writing not allowed + [HoldingRegister(12484, writable: true)] private UInt32 _accreditedCountries; */ // 0x30C4 // to be verified, writing not allowed [HoldingRegister(12618, writable: true)] private UInt32 _battery1BackupSOC; // 0x314A [HoldingRegister(12620, writable: true)] private UInt32 _battery2BackupSOC; // 0x314C - [HoldingRegister(12746, writable: true)] private UInt32 _enableGridExport; // 0x314A - [HoldingRegister(12748, writable: true)] private UInt32 _powerGridExportLimit; // 0x314C + [HoldingRegister(12746, writable: true)] private UInt32 _enableGridExport; // 0x31CA + [HoldingRegister(12748, writable: true)] private UInt32 _powerGridExportLimit; // 0x31CC // ─────────────────────────────────────────────── From 97055b174934e4f83c9813a171d55eb188595e47 Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:25:11 +0200 Subject: [PATCH 13/22] Update Modbus tcp creation file and cleane the program.cs code --- csharp/App/GrowattCommunication/Program.cs | 59 ++++++++-------------- 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/csharp/App/GrowattCommunication/Program.cs b/csharp/App/GrowattCommunication/Program.cs index 78461c1c9..01de544a2 100644 --- a/csharp/App/GrowattCommunication/Program.cs +++ b/csharp/App/GrowattCommunication/Program.cs @@ -32,7 +32,7 @@ namespace InnovEnergy.App.GrowattCommunication; public static class Program { - private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(6); + private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(10); private const UInt16 NbrOfFileToConcatenate = 15; // add this to config file private static UInt16 _fileCounter = 0; // @@ -165,6 +165,10 @@ public static class Program statusrecord.InverterRecord.BatteryOperatingMode.WriteLine(" = BatteryOperatingMode"); statusrecord.InverterRecord.OperatingMode.WriteLine(" = OperatingPriority"); // 30408 this the duration + statusrecord.InverterRecord.ExportLimitationEnabled.WriteLine(" = ExportLimitationEnabled"); + statusrecord.InverterRecord.ExportLimitationPowerRate.WriteLine(" = ExportLimitationPowerRate"); // 30408 this the duration + + statusrecord.InverterRecord.FaultMainCode.WriteLine(" = FaultMainCode"); // 30408 this the duration statusrecord.InverterRecord.FaultSubCode.WriteLine(" = FaultSubCode"); // 30408 this the duration statusrecord.InverterRecord.WarningMainCode.WriteLine(" = WarningMainCode"); // 30408 this the duration @@ -352,12 +356,7 @@ public static class Program var stateChanged = currentSalimaxState.Status != _prevSodiohomeAlarmState; var contentChanged = HasErrorsOrWarningsChanged(currentSalimaxState); var needsHeartbeat = (DateTime.Now - _lastHeartbeatTime).TotalSeconds >= HeartbeatIntervalSeconds; - Console.WriteLine($"subscribedNow={subscribedNow}"); - Console.WriteLine($"_subscribedToQueue={_subscribedToQueue}"); - Console.WriteLine($"stateChanged={stateChanged}"); - Console.WriteLine($"contentChanged={contentChanged}"); - Console.WriteLine($"needsHeartbeat={needsHeartbeat}"); - Console.WriteLine($"s3Bucket null? {s3Bucket == null}"); + if (s3Bucket == null) { @@ -423,7 +422,7 @@ public static class Program st.InverterRecord.EnableEmsCommunicationFailureTime = false; st.InverterRecord.EnableCommand = true; st.InverterRecord.ControlPermission = st.Config.ControlPermission;; - st.InverterRecord.MaxSoc = 100; //st.Config.MaxSoc; + // st.InverterRecord.MaxSoc = 100; //st.Config.MaxSoc; } private static Dictionary ConvertToModbusRegisters(Object value, String outputType, @@ -488,12 +487,10 @@ public static class Program var modbusData = new Dictionary(); // SYSTEM DATA - var result1 = - ConvertToModbusRegisters((status.Config.ModbusProtcolNumber * 10), "UInt16", - 30001); // this to be updated to modbusTCP version + var result1 = ConvertToModbusRegisters((status.Config.ModbusProtcolNumber * 10), "UInt16", 30001); // this to be updated to modbusTCP version var result2 = ConvertToModbusRegisters(status.InverterRecord.SystemDateTime.ToUnixTime(), "UInt32", 30002); - var result3 = ConvertToModbusRegisters(status.InverterRecord.SystemOperatingMode, "UInt16", 30004); - var result17 = ConvertToModbusRegisters(status.InverterRecord.OperatingMode, "UInt16", 30005); + //var result3 = ConvertToModbusRegisters(status.InverterRecord.SystemOperatingMode, "UInt16", 30005); + var result17 = ConvertToModbusRegisters(status.InverterRecord.OperatingMode, "UInt16", 30004); // BATTERY SUMMARY (assuming single battery [0]) // this to be improved @@ -505,24 +502,29 @@ public static class Program var result5 = ConvertToModbusRegisters((status.InverterRecord.Battery1Power.Value * 10), "Int32", 31006); var result7 = ConvertToModbusRegisters((status.InverterRecord.MinSoc * 100), "UInt16", 31008); - var result20 = ConvertToModbusRegisters((status.InverterRecord.ChargeCutoffSocVoltage * 100), "UInt16", 31009); - var result15 = - ConvertToModbusRegisters((status.InverterRecord.Battery1Soh * 100), "UInt16", 310010); + var result20 = ConvertToModbusRegisters((status.InverterRecord.MaxSoc * 100), "UInt16", 31009); + var result15 = ConvertToModbusRegisters((status.InverterRecord.Battery1Soh * 100), "UInt16", 31010); var result16 = ConvertToModbusRegisters((status.InverterRecord.Battery1AmbientTemperature.Value * 100), "UInt16", 31011); var result21 = ConvertToModbusRegisters((status.InverterRecord.BatteryMaxChargingCurrent * 10), "UInt16", 31012); var result22 = ConvertToModbusRegisters((status.InverterRecord.BatteryMaxDischargingCurrent * 10), "UInt16", 31013); - var result23 = ConvertToModbusRegisters((status.InverterRecord.MaxSoc * 10), "UInt16", 31014); - + + var result23 = ConvertToModbusRegisters((status.InverterRecord.ChargeCutoffSocVoltage * 10), "UInt16", 31014); var result18 = ConvertToModbusRegisters((status.InverterRecord.PvPower.Value * 10), "UInt32", 32000); var result19 = ConvertToModbusRegisters((status.InverterRecord.GridPower * 10), "Int32", 33000); + var result3 = ConvertToModbusRegisters((status.InverterRecord.Frequency * 10), "Int32", 33002); - + var result24 = ConvertToModbusRegisters((status.InverterRecord.OperatingMode ), "UInt16", 34000); + var result25 = ConvertToModbusRegisters((status.InverterRecord.InverterActivePower * 10), "Int32", 34001); + var result26 = ConvertToModbusRegisters((status.Config.GridSetPoint * 10), "Int32", 35000); + var result27 = ConvertToModbusRegisters((status.InverterRecord.ExportLimitationEnabled * 10), "Int32", 35002); + var result28 = ConvertToModbusRegisters((status.InverterRecord.ExportLimitationPowerRate * 10), "Int32", 35003); + // Merge all results into one dictionary var allResults = new[] { result1, result2, result3, result17, result4, result5, result7, result8, result12, result13, result14, result15, result16, result18, result19, result20, - result21, result22, result23 + result21, result22, result23, result24, result25, result26, result27, result28, }; foreach (var result in allResults) @@ -717,24 +719,7 @@ public static class Program 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) { From c7fd6eedd1868227a2aad365c2791ac71a75a6c7 Mon Sep 17 00:00:00 2001 From: atef Date: Fri, 10 Apr 2026 15:25:33 +0200 Subject: [PATCH 14/22] Created Hourlyenergy data fro growatt but not used and not finished yet --- .../AggregationService/HourlyEnergyData.cs | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 csharp/App/GrowattCommunication/AggregationService/HourlyEnergyData.cs diff --git a/csharp/App/GrowattCommunication/AggregationService/HourlyEnergyData.cs b/csharp/App/GrowattCommunication/AggregationService/HourlyEnergyData.cs new file mode 100644 index 000000000..c6b20d919 --- /dev/null +++ b/csharp/App/GrowattCommunication/AggregationService/HourlyEnergyData.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using InnovEnergy.App.GrowattCommunication.ESS; + +namespace InnovEnergy.App.GrowattCommunication.AggregationService; + +public class HourlyAccumulator +{ + public DateTime HourStart { get; set; } + + public double StartSelfGeneratedElectricity { get; set; } + public double StartElectricityPurchased { get; set; } + public double StartElectricityFed { get; set; } + public double StartBatteryChargeEnergy { get; set; } + public double StartBatteryDischargeEnergy { get; set; } + public double StartLoadPowerConsumption { get; set; } + + public double LastSelfGeneratedElectricity { get; set; } + public double LastElectricityPurchased { get; set; } + public double LastElectricityFed { get; set; } + public double LastBatteryChargeEnergy { get; set; } + public double LastBatteryDischargeEnergy { get; set; } + public double LastLoadPowerConsumption { get; set; } +} + +public static class EnergyAggregation +{ + private static HourlyAccumulator? _currentHourAccumulator; + private static DateTime? _lastDailySaveDate; +/* + + public static HourlyEnergyData? ProcessHourlyData(StatusRecord statusRecord, DateTime timestamp) + { + var r = statusRecord.InverterRecord; + var hourStart = new DateTime(timestamp.Year, timestamp.Month, timestamp.Day, timestamp.Hour, 0, 0); + + // First call + if (_currentHourAccumulator == null) + { + _currentHourAccumulator = new HourlyAccumulator + { + HourStart = hourStart, + + StartSelfGeneratedElectricity = r.SelfGeneratedElectricity, + StartElectricityPurchased = r.ElectricityPurchased, + StartElectricityFed = r.ElectricityFed, + StartBatteryChargeEnergy = r.BatteryChargeEnergy, + StartBatteryDischargeEnergy = r.BatteryDischargeEnergy, + StartLoadPowerConsumption = r.LoadPowerConsumption, + + LastSelfGeneratedElectricity = r.SelfGeneratedElectricity, + LastElectricityPurchased = r.ElectricityPurchased, + LastElectricityFed = r.ElectricityFed, + LastBatteryChargeEnergy = r.BatteryChargeEnergy, + LastBatteryDischargeEnergy = r.BatteryDischargeEnergy, + LastLoadPowerConsumption = r.LoadPowerConsumption + }; + + return null; + } + + // Still same hour → just update last values + if (_currentHourAccumulator.HourStart == hourStart) + { + _currentHourAccumulator.LastSelfGeneratedElectricity = r.SelfGeneratedElectricity; + _currentHourAccumulator.LastElectricityPurchased = r.ElectricityPurchased; + _currentHourAccumulator.LastElectricityFed = r.ElectricityFed; + _currentHourAccumulator.LastBatteryChargeEnergy = r.BatteryChargeEnergy; + _currentHourAccumulator.LastBatteryDischargeEnergy = r.BatteryDischargeEnergy; + _currentHourAccumulator.LastLoadPowerConsumption = r.LoadPowerConsumption; + + return null; + } + + // Hour changed → finalize previous hour + var completedHour = new HourlyEnergyData + { + Timestamp = _currentHourAccumulator.HourStart, + + SelfGeneratedElectricity = SafeDiff( + _currentHourAccumulator.LastSelfGeneratedElectricity, + _currentHourAccumulator.StartSelfGeneratedElectricity), + + ElectricityPurchased = SafeDiff( + _currentHourAccumulator.LastElectricityPurchased, + _currentHourAccumulator.StartElectricityPurchased), + + ElectricityFed = SafeDiff( + _currentHourAccumulator.LastElectricityFed, + _currentHourAccumulator.StartElectricityFed), + + BatteryChargeEnergy = SafeDiff( + _currentHourAccumulator.LastBatteryChargeEnergy, + _currentHourAccumulator.StartBatteryChargeEnergy), + + BatteryDischargeEnergy = SafeDiff( + _currentHourAccumulator.LastBatteryDischargeEnergy, + _currentHourAccumulator.StartBatteryDischargeEnergy), + + LoadPowerConsumption = SafeDiff( + _currentHourAccumulator.LastLoadPowerConsumption, + _currentHourAccumulator.StartLoadPowerConsumption) + }; + + // Start new hour with current sample + _currentHourAccumulator = new HourlyAccumulator + { + HourStart = hourStart, + + StartSelfGeneratedElectricity = r.SelfGeneratedElectricity, + StartElectricityPurchased = r.ElectricityPurchased, + StartElectricityFed = r.ElectricityFed, + StartBatteryChargeEnergy = r.BatteryChargeEnergy, + StartBatteryDischargeEnergy = r.BatteryDischargeEnergy, + StartLoadPowerConsumption = r.LoadPowerConsumption, + + LastSelfGeneratedElectricity = r.SelfGeneratedElectricity, + LastElectricityPurchased = r.ElectricityPurchased, + LastElectricityFed = r.ElectricityFed, + LastBatteryChargeEnergy = r.BatteryChargeEnergy, + LastBatteryDischargeEnergy = r.BatteryDischargeEnergy, + LastLoadPowerConsumption = r.LoadPowerConsumption + }; + + return completedHour; + }*/ + + public static DailyEnergyData? TryCreateDailyData(StatusRecord statusRecord, DateTime timestamp) + { + if (timestamp is { Hour: 23, Minute: 59 }) + { + if (_lastDailySaveDate != timestamp.Date) + { + _lastDailySaveDate = timestamp.Date; + + var r = statusRecord.InverterRecord; + + return new DailyEnergyData + { + Timestamp = timestamp, + + // DailySelfGeneratedElectricity = r.DailySelfGeneratedElectricity, + DailyElectricityPurchased = r.EnergyToUser, + DailyElectricityFed = r.EnergyToGrid, + BatteryDailyChargeEnergy = r.BatteryDailyChargeEnergy, + BatteryDailyDischargeEnergy = r.BatteryDailyDischargeEnergy, + // DailyLoadPowerConsumption = r.DailyLoadPowerConsumption + }; + } + } + + return null; + } + + private static double SafeDiff(double endValue, double startValue) + { + var diff = endValue - startValue; + return diff < 0 ? 0 : diff; + } +} \ No newline at end of file From 18d47232b739d2995f1ae6bb5aaa8b13fca0e227 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Mon, 13 Apr 2026 10:38:18 +0200 Subject: [PATCH 15/22] enter key chains through the battery S/N fields --- .../Information/InformationSodistoreHome.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx index 1c3d18bd8..eaa605f3b 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx @@ -1020,11 +1020,30 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { {Array.from({ length: batteryCount }, (_, batIdx) => ( handleBatterySnTreeChange(invIdx, clIdx, batIdx, e.target.value) } + onKeyDown={(e) => { + if (e.key === 'Enter' && presetConfig) { + e.preventDefault(); + let nextInv = invIdx, nextCl = clIdx, nextBat = batIdx + 1; + if (nextBat >= presetConfig[invIdx][clIdx]) { + nextBat = 0; + nextCl = clIdx + 1; + if (nextCl >= presetConfig[invIdx].length) { + nextCl = 0; + nextInv = invIdx + 1; + if (nextInv >= presetConfig.length) return; + } + } + const nextId = `bat-${nextInv}-${nextCl}-${nextBat}`; + const nextInput = document.getElementById(nextId) as HTMLInputElement | null; + if (nextInput) nextInput.focus(); + } + }} variant="outlined" fullWidth placeholder={canEdit ? 'Scan or enter serial number' : ''} From b8d67f7926cabb1e915f46ce039ca65f6c0419c6 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 14 Apr 2026 13:09:10 +0200 Subject: [PATCH 16/22] added sodistore home 13.5 setup for sinexcel --- .../dashboards/Information/installationSetupUtils.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Information/installationSetupUtils.ts b/typescript/frontend-marios2/src/content/dashboards/Information/installationSetupUtils.ts index 37fc70d4a..2756f3d97 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Information/installationSetupUtils.ts +++ b/typescript/frontend-marios2/src/content/dashboards/Information/installationSetupUtils.ts @@ -20,10 +20,11 @@ export const INSTALLATION_PRESETS: Record> 'sodistore home 18': [[4]], }, 4: { - 'sodistore home 9': [[1, 1]], - 'sodistore home 18': [[2, 2]], - 'sodistore home 27': [[2, 2], [1, 1]], - 'sodistore home 36': [[2, 2], [2, 2]], + 'sodistore home 9': [[1, 1]], + 'sodistore home 13.5': [[2, 1]], + 'sodistore home 18': [[2, 2]], + 'sodistore home 27': [[2, 2], [1, 1]], + 'sodistore home 36': [[2, 2], [2, 2]], }, }; From 52c9a42e429717fe5ff68651147b352681b80eb4 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 14 Apr 2026 13:35:53 +0200 Subject: [PATCH 17/22] a ticket must be assigned to someone when creating --- .../dashboards/Tickets/CreateTicketModal.tsx | 56 ++++++++++++++++++- .../dashboards/Tickets/TicketDetail.tsx | 18 ++++-- typescript/frontend-marios2/src/lang/de.json | 1 + typescript/frontend-marios2/src/lang/en.json | 1 + typescript/frontend-marios2/src/lang/fr.json | 1 + typescript/frontend-marios2/src/lang/it.json | 1 + 6 files changed, 71 insertions(+), 7 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx index 7d6f64087..4dc2ddb03 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx @@ -19,7 +19,7 @@ import { Typography } from '@mui/material'; import AttachFileIcon from '@mui/icons-material/AttachFile'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; import { TicketPriority, @@ -28,7 +28,8 @@ import { subCategoryLabels, subCategoriesByCategory, categoryLabels, - otherSubCategoryValues + otherSubCategoryValues, + AdminUser } from 'src/interfaces/TicketTypes'; type Installation = { @@ -65,6 +66,7 @@ interface Props { } function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: Props) { + const intl = useIntl(); const [subject, setSubject] = useState(''); const [selectedProduct, setSelectedProduct] = useState(''); const [selectedDevice, setSelectedDevice] = useState(''); @@ -73,6 +75,8 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: useState(null); const [loadingInstallations, setLoadingInstallations] = useState(false); const [priority, setPriority] = useState(TicketPriority.Medium); + const [assigneeId, setAssigneeId] = useState(''); + const [adminUsers, setAdminUsers] = useState([]); const [category, setCategory] = useState(TicketCategory.Hardware); const [subCategory, setSubCategory] = useState( TicketSubCategory.Battery @@ -189,6 +193,16 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: .finally(() => setLoadingInstallations(false)); }, [selectedProduct]); + useEffect(() => { + if (!open) return; + axiosConfig + .get('/GetAdminUsers') + .then((res) => { + if (Array.isArray(res.data)) setAdminUsers(res.data); + }) + .catch(() => setAdminUsers([])); + }, [open]); + useEffect(() => { if (defaultInstallationId == null || !open) return; axiosConfig @@ -233,6 +247,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: setAllInstallations([]); setSelectedInstallation(null); setPriority(TicketPriority.Medium); + setAssigneeId(''); setCategory(TicketCategory.Hardware); setSubCategory(TicketSubCategory.Battery); setDescription(''); @@ -244,6 +259,15 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: const handleSubmit = async () => { if (!subject.trim()) return; + if (assigneeId === '') { + setError( + intl.formatMessage({ + id: 'assigneeRequired', + defaultMessage: 'Please assign this ticket to someone before creating it.' + }) + ); + return; + } setSubmitting(true); setError(''); @@ -253,6 +277,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: description, installationId: selectedInstallation?.id ?? null, priority, + assigneeId, category, subCategory: isOtherCategory ? 0 : subCategory, customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null, @@ -390,6 +415,33 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: )} /> + + + + + + + diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx index 7cecf3482..bb00c7e02 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx @@ -591,11 +591,19 @@ function TicketDetailPage() { /> - {adminUsers.map((u) => ( - - {u.name} - - ))} + {adminUsers + .filter((u) => { + const name = (u.name ?? '').toLowerCase(); + return ( + !name.includes('inesco energy master admin') && + !name.includes('paal myhre') + ); + }) + .map((u) => ( + + {u.name} + + ))} diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 90b785b1b..d05706670 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -574,6 +574,7 @@ "resolvedAt": "Gelöst", "noDescription": "Keine Beschreibung vorhanden.", "assignee": "Zuständig", + "assigneeRequired": "Bitte weisen Sie dieses Ticket jemandem zu, bevor Sie es erstellen.", "unassigned": "Nicht zugewiesen", "deleteTicket": "Löschen", "confirmDeleteTicket": "Ticket löschen?", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 09f680f5a..ddf33dcde 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -322,6 +322,7 @@ "resolvedAt": "Resolved", "noDescription": "No description provided.", "assignee": "Assignee", + "assigneeRequired": "Please assign this ticket to someone before creating it.", "unassigned": "Unassigned", "deleteTicket": "Delete", "confirmDeleteTicket": "Delete Ticket?", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 702f73b7d..79aa856bd 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -574,6 +574,7 @@ "resolvedAt": "Résolu", "noDescription": "Aucune description fournie.", "assignee": "Responsable", + "assigneeRequired": "Veuillez assigner ce ticket à quelqu'un avant de le créer.", "unassigned": "Non assigné", "deleteTicket": "Supprimer", "confirmDeleteTicket": "Supprimer le ticket ?", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index fef93ad05..4cbfbdaea 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -574,6 +574,7 @@ "resolvedAt": "Risolto", "noDescription": "Nessuna descrizione fornita.", "assignee": "Assegnatario", + "assigneeRequired": "Assegna questo ticket a qualcuno prima di crearlo.", "unassigned": "Non assegnato", "deleteTicket": "Elimina", "confirmDeleteTicket": "Eliminare il ticket?", From 5bced9374b78dbe0745fa5c64b3f2ab3205ea067 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 14 Apr 2026 15:56:22 +0200 Subject: [PATCH 18/22] added yes or no data collection mode --- csharp/App/Backend/DataTypes/Installation.cs | 1 + csharp/App/Backend/Database/Db.cs | 5 + .../DeleteOldData/DeleteOldDataFromS3.cs | 2 +- .../Backend/Services/DailyIngestionService.cs | 14 +- .../Services/ReportAggregationService.cs | 2 +- .../Information/InformationSodistoreHome.tsx | 22 +++ .../FlatInstallationView.tsx | 82 +++++----- .../SodiohomeInstallations/Installation.tsx | 143 ++++++++++-------- .../SodistorehomeInstallationForm.tsx | 41 +++++ .../SodiohomeInstallations/index.tsx | 8 + .../dashboards/Tree/CustomTreeItem.tsx | 88 ++++++----- .../src/interfaces/InstallationTypes.tsx | 1 + typescript/frontend-marios2/src/lang/de.json | 3 + typescript/frontend-marios2/src/lang/en.json | 3 + typescript/frontend-marios2/src/lang/fr.json | 3 + typescript/frontend-marios2/src/lang/it.json | 3 + 16 files changed, 285 insertions(+), 136 deletions(-) diff --git a/csharp/App/Backend/DataTypes/Installation.cs b/csharp/App/Backend/DataTypes/Installation.cs index 54327005a..f5d6cbe48 100644 --- a/csharp/App/Backend/DataTypes/Installation.cs +++ b/csharp/App/Backend/DataTypes/Installation.cs @@ -48,6 +48,7 @@ public class Installation : TreeNode public String ReadRoleId { get; set; } = ""; public String WriteRoleId { get; set; } = ""; public Boolean TestingMode { get; set; } = false; + public Boolean DataCollectionEnabled { get; set; } = true; public int Status { get; set; } = -1; public int Product { get; set; } = (int)ProductType.Salimax; public int Device { get; set; } = 0; diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 3ee45c6bb..fe5a8770c 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -91,6 +91,11 @@ public static partial class Db Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'"); Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'"); + // Backfill: SQLite-net adds new bool columns as nullable with NULL for existing rows. + // LINQ `.Where(i => i.DataCollectionEnabled)` translates to `WHERE ... = 1` and excludes + // NULL rows, which would silently disable ingestion for every pre-existing installation. + Connection.Execute("UPDATE Installation SET DataCollectionEnabled = 1 WHERE DataCollectionEnabled IS NULL"); + // One-time migration: rebrand to inesco energy Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'"); Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'"); diff --git a/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs b/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs index d6e104823..6eb53179b 100644 --- a/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs +++ b/csharp/App/Backend/DeleteOldData/DeleteOldDataFromS3.cs @@ -39,7 +39,7 @@ public static class DeleteOldDataFromS3 { var cutoffTimestamp = DateTimeOffset.UtcNow.AddYears(-1).ToUnixTimeSeconds(); var cutoffKey = cutoffTimestamp.ToString(); - var installations = Db.Installations.ToList(); + var installations = Db.Installations.Where(i => i.DataCollectionEnabled).ToList(); Console.WriteLine($"[S3Cleanup] Starting cleanup for {installations.Count} installations, cutoff: {cutoffKey}"); diff --git a/csharp/App/Backend/Services/DailyIngestionService.cs b/csharp/App/Backend/Services/DailyIngestionService.cs index 920b18a52..71981bb7a 100644 --- a/csharp/App/Backend/Services/DailyIngestionService.cs +++ b/csharp/App/Backend/Services/DailyIngestionService.cs @@ -50,7 +50,7 @@ public static class DailyIngestionService Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations..."); var installations = Db.Installations - .Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3) + .Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3 && i.DataCollectionEnabled) // Skip Growatt (device=3) and installations with data collection disabled .ToList(); foreach (var installation in installations) @@ -75,6 +75,13 @@ public static class DailyIngestionService /// public static async Task IngestInstallationAsync(Int64 installationId) { + var installation = Db.GetInstallationById(installationId); + if (installation is null || !installation.DataCollectionEnabled) + { + Console.WriteLine($"[DailyIngestion] Skipping installation {installationId} (data collection disabled)."); + return; + } + await TryIngestFromJson(installationId); IngestFromXlsx(installationId); } @@ -88,6 +95,11 @@ public static class DailyIngestionService { var installation = Db.GetInstallationById(installationId); if (installation is null) return; + if (!installation.DataCollectionEnabled) + { + Console.WriteLine($"[DailyIngestion] Skipping date-range ingest for installation {installationId} (data collection disabled)."); + return; + } var newDaily = 0; var newHourly = 0; diff --git a/csharp/App/Backend/Services/ReportAggregationService.cs b/csharp/App/Backend/Services/ReportAggregationService.cs index ec71be1b2..0bc601ee8 100644 --- a/csharp/App/Backend/Services/ReportAggregationService.cs +++ b/csharp/App/Backend/Services/ReportAggregationService.cs @@ -106,7 +106,7 @@ public static class ReportAggregationService Console.WriteLine("[ReportAggregation] Running Monday weekly report generation..."); var installations = Db.Installations - .Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3) + .Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3 && i.DataCollectionEnabled) // Skip Growatt (device=3) and installations with data collection disabled .ToList(); var generated = 0; diff --git a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx index eaa605f3b..3c87ad13f 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx @@ -762,6 +762,28 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { /> +
+ + + + + + +
+
diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/FlatInstallationView.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/FlatInstallationView.tsx index 13ddbb325..374900ece 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/FlatInstallationView.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/FlatInstallationView.tsx @@ -209,46 +209,60 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => { marginLeft: '15px' }} > - {status === -1 ? ( - ) : ( - '' - )} + <> + {status === -1 ? ( + + ) : ( + '' + )} - {status === -2 ? ( - - ) : ( - '' - )} + {status === -2 ? ( + + ) : ( + '' + )} -
+
+ + )} {installation.testingMode && ( (undefined); const [values, setValues] = useState(null); const status = props.current_installation.status; + const dataCollectionDisabled = props.current_installation.dataCollectionEnabled === false; const [ failedToCommunicateWithInstallation, setFailedToCommunicateWithInstallation @@ -417,46 +418,60 @@ function SodioHomeInstallation(props: singleInstallationProps) { marginTop: '-10px' }} > - {status === -1 ? ( - ) : ( - '' - )} + <> + {status === -1 ? ( + + ) : ( + '' + )} - {status === -2 ? ( - - ) : ( - '' - )} + {status === -2 ? ( + + ) : ( + '' + )} -
+
+ + )} {props.current_installation.testingMode && ( - {currentUser.userType !== UserType.client && ( + {currentUser.userType !== UserType.client && !dataCollectionDisabled && ( )} - - } - /> + {!dataCollectionDisabled && ( + + } + /> + )} - {currentUser.userType !== UserType.client && ( + {currentUser.userType !== UserType.client && !dataCollectionDisabled && ( )} - {currentUser.userType == UserType.admin && ( + {currentUser.userType == UserType.admin && !dataCollectionDisabled && ( )} */} - - } - /> + {!dataCollectionDisabled && ( + + } + /> + )} - {props.current_installation.device !== 3 && ( + {props.current_installation.device !== 3 && !dataCollectionDisabled && ( )} +
+ + + + + + +
+
{tabs .filter((tab) => !(isGrowatt && tab.value === 'report')) + .filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value)) .map((tab) => ( {singleInstallationTabs .filter((tab) => !(isGrowatt && tab.value === 'report')) + .filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value)) .map((tab) => ( - {status === -1 ? ( - ) : ( - '' - )} + <> + {status === -1 ? ( + + ) : ( + '' + )} - {status === -2 ? ( - - ) : ( - '' - )} + {status === -2 ? ( + + ) : ( + '' + )} -
+
+ + )}
)}
diff --git a/typescript/frontend-marios2/src/interfaces/InstallationTypes.tsx b/typescript/frontend-marios2/src/interfaces/InstallationTypes.tsx index 1d91e5680..a90fd1c90 100644 --- a/typescript/frontend-marios2/src/interfaces/InstallationTypes.tsx +++ b/typescript/frontend-marios2/src/interfaces/InstallationTypes.tsx @@ -36,6 +36,7 @@ export interface I_Installation extends I_S3Credentials { product: number; device: number; testingMode?: boolean; + dataCollectionEnabled?: boolean; status?: number; serialNumber?: string; networkProvider: string; diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index d05706670..798a2068a 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -86,6 +86,9 @@ "externalEmsOther": "Externes EMS (angeben)", "emsNo": "Nein", "emsOther": "Andere", + "yes": "Ja", + "no": "Nein", + "dataCollectionEnabled": "Datenerfassung", "generalInfo": "Allgemeine Informationen", "installationSetup": "Installationseinrichtung", "couplingType": "AC/DC-Kopplung", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index ddf33dcde..21de73b3a 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -68,6 +68,9 @@ "externalEmsOther": "External EMS (specify)", "emsNo": "No", "emsOther": "Other", + "yes": "Yes", + "no": "No", + "dataCollectionEnabled": "Data Collection", "generalInfo": "General Info", "installationSetup": "Installation Setup", "couplingType": "AC/DC Coupling", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 79aa856bd..e99292d0e 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -80,6 +80,9 @@ "externalEmsOther": "EMS externe (préciser)", "emsNo": "Non", "emsOther": "Autre", + "yes": "Oui", + "no": "Non", + "dataCollectionEnabled": "Collecte de données", "generalInfo": "Informations générales", "installationSetup": "Configuration de l'installation", "couplingType": "Couplage AC/DC", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 4cbfbdaea..151ba9bf9 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -68,6 +68,9 @@ "externalEmsOther": "EMS esterno (specificare)", "emsNo": "No", "emsOther": "Altro", + "yes": "Sì", + "no": "No", + "dataCollectionEnabled": "Raccolta dati", "generalInfo": "Informazioni generali", "installationSetup": "Configurazione installazione", "couplingType": "Accoppiamento AC/DC", From dde3b9794c2a7d7643e8383fa5ce8792a3714a43 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 14 Apr 2026 16:18:57 +0200 Subject: [PATCH 19/22] show sum battery power and avg soc on Live View for Sodistore Home, Pro, Grid --- .../dashboards/Installations/Installation.tsx | 21 +++---- .../Topology/TopologySodistoreHome.tsx | 58 +++++++------------ .../dashboards/Topology/topologyBox.tsx | 9 +-- 3 files changed, 32 insertions(+), 56 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx index dcb80784c..0439276b5 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx @@ -25,6 +25,7 @@ import Information from '../Information/Information'; import { UserType } from '../../../interfaces/UserTypes'; import HistoryOfActions from '../History/History'; import Topology from '../Topology/Topology'; +import TopologySodistoreHome from '../Topology/TopologySodistoreHome'; import BatteryView from '../BatteryView/BatteryView'; import Configuration from '../Configuration/Configuration'; import PvView from '../PvView/PvView'; @@ -465,20 +466,12 @@ function Installation(props: singleInstallationProps) { path={routes.live} element={ props.current_installation.product === 4 ? ( - // TODO: SodistoreGrid — implement actual topology layout - - - Live view coming soon - - + ) : ( { + const socs = Array.from({ length: props.batteryClusterNumber }) + .map((_, i) => Number(inv?.[`Battery${i + 1}Soc`]) || 0); + return socs.length ? socs.reduce((s, v) => s + v, 0) / socs.length : 0; + })(); + return ( @@ -255,42 +263,20 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) { isFirst={false} /> {/*-------------------------------------------------------------------------------------------------------------------------------------------------------------*/} - {Array.from({ length: props.batteryClusterNumber }).map((_, index) => { - let soc: number; - let power: number; - - if (hasDevices) { - // Sinexcel: map across devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, 3→D2/B2 - const deviceId = String(Math.floor(index / 2) + 1); - const batteryIndex = (index % 2) + 1; - const device = inv?.Devices?.[deviceId]; - soc = device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0; - power = device?.[`Battery${batteryIndex}Power`] ?? 0; - } else { - // Growatt: flat Battery1, Battery2, ... - const i = index + 1; - soc = Number(inv?.[`Battery${i}Soc`]) || 0; - power = Number(inv?.[`Battery${i}Power`]) || 0; - } - - return ( - - ); - })} + )} diff --git a/typescript/frontend-marios2/src/content/dashboards/Topology/topologyBox.tsx b/typescript/frontend-marios2/src/content/dashboards/Topology/topologyBox.tsx index 0c7adf1e9..2af57880b 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Topology/topologyBox.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Topology/topologyBox.tsx @@ -257,14 +257,11 @@ function TopologyBox(props: TopologyBoxProps) { }} > {props.data.map((boxData, index) => { + const formatted = formatPower(boxData.value, boxData.unit); return ( - {formatPower(boxData.value, boxData.unit) === 0 - ? null - : formatPower(boxData.value, boxData.unit)} - {formatPower(boxData.value, boxData.unit) === 0 - ? null - : boxData.unit} + {formatted === 0 ? '0 ' : formatted} + {boxData.unit} ); })} From 45a3c6260985d9fdf1abf1b27528601fe1e3125f Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 14 Apr 2026 16:41:12 +0200 Subject: [PATCH 20/22] solve unit missing issue when there are many battery clusters --- .../src/content/dashboards/Overview/chartOptions.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx b/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx index 6e4df3c25..64ec1133f 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx @@ -48,6 +48,11 @@ export const getChartOptions = ( curve: 'smooth', width: 2 }, + grid: { + padding: { + top: 30 + } + }, yaxis: type === 'dailyoverview' ? [ From 544f9602e12a91e99560929ee5b7b841f78c6a47 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 15 Apr 2026 15:28:24 +0200 Subject: [PATCH 21/22] adapt data path for growatt and sinexcel --- .../src/content/dashboards/Log/graph.util.tsx | 6 +++- .../SodistoreHomeConfiguration.tsx | 4 +-- .../Topology/TopologySodistoreHome.tsx | 29 ++++++++++--------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx index 0f8124d56..57b2d2bbe 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx @@ -1140,7 +1140,11 @@ export const getHighestConnectionValue = (values: JSONRecordData) => { 'InverterRecord.PvPower', 'InverterRecord.Battery1Power', 'InverterRecord.Battery2Power', - 'InverterRecord.ConsumptionPower' + 'InverterRecord.ConsumptionPower', + 'InverterRecord.TotalBatteryPower', + 'InverterRecord.TotalPhotovoltaicPower', + 'InverterRecord.TotalLoadPower', + 'InverterRecord.TotalGridPower' ]; // Helper function to safely get a value from a nested path diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx index a6caf051b..aafbbd349 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx @@ -91,8 +91,8 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { // Helper to build form values from S3 data const getS3Values = (): Partial => ({ minimumSoC: props.values.Config.MinSoc, - maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent, - maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent, + maximumDischargingCurrent: props.values.Config.MaximumDischargingCurrent, + maximumChargingCurrent: props.values.Config.MaximumChargingCurrent, operatingPriority: resolveOperatingPriorityIndex( props.values.Config.OperatingPriority ), diff --git a/typescript/frontend-marios2/src/content/dashboards/Topology/TopologySodistoreHome.tsx b/typescript/frontend-marios2/src/content/dashboards/Topology/TopologySodistoreHome.tsx index e1003ff3d..8cae6fedb 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Topology/TopologySodistoreHome.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Topology/TopologySodistoreHome.tsx @@ -42,21 +42,21 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) { const inv = props.values?.InverterRecord; const hasDevices = !!inv?.Devices; + const growattActiveIndices: number[] = hasDevices + ? [] + : Array.from({ length: props.batteryClusterNumber }, (_, i) => i + 1) + .filter((i) => Number(inv?.[`Battery${i}Voltage`]) > 0); + const totalBatteryPower: number = hasDevices ? (inv?.TotalBatteryPower ?? 0) - : Number( - Array.from({ length: props.batteryClusterNumber }).reduce( - (sum: number, _, index) => sum + (Number(inv?.[`Battery${index + 1}Power`]) || 0), - 0 - ) + : growattActiveIndices.reduce( + (sum, i) => sum + (Number(inv?.[`Battery${i}Power`]) || 0), + 0 ); const pvPower: number = hasDevices ? (inv?.TotalPhotovoltaicPower ?? 0) - : (inv?.PvPower ?? - ['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4'] - .map((key) => inv?.[key] ?? 0) - .reduce((sum, val) => sum + val, 0)); + : (inv?.PvPower ?? 0); const totalLoadPower: number = hasDevices ? (inv?.TotalLoadPower ?? 0) @@ -67,11 +67,12 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) { const avgBatterySoc: number = hasDevices ? (inv?.AvgBatterySoc ?? 0) - : (() => { - const socs = Array.from({ length: props.batteryClusterNumber }) - .map((_, i) => Number(inv?.[`Battery${i + 1}Soc`]) || 0); - return socs.length ? socs.reduce((s, v) => s + v, 0) / socs.length : 0; - })(); + : (growattActiveIndices.length + ? growattActiveIndices.reduce( + (sum, i) => sum + (Number(inv?.[`Battery${i}Soc`]) || 0), + 0 + ) / growattActiveIndices.length + : 0); return ( From 3fbb2eeee00aef5493b8048108253fd8803bd5e7 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 15 Apr 2026 16:15:03 +0200 Subject: [PATCH 22/22] add notificiation system to ticket dashboard --- csharp/App/Backend/Controller.cs | 76 ++++++++- csharp/App/Backend/DataTypes/Methods/User.cs | 159 ++++++++++++++++++ csharp/App/Backend/DataTypes/TicketComment.cs | 2 + .../Backend/DataTypes/TicketCommentMention.cs | 12 ++ csharp/App/Backend/Database/Create.cs | 1 + csharp/App/Backend/Database/Db.cs | 1 + .../dashboards/Tickets/CommentThread.tsx | 111 +++++++++++- .../Tickets/InstallationTicketsTab.tsx | 4 +- .../content/dashboards/Tickets/StatusChip.tsx | 2 +- .../dashboards/Tickets/TicketDetail.tsx | 45 ++++- .../content/dashboards/Tickets/TicketList.tsx | 4 +- typescript/frontend-marios2/src/lang/de.json | 5 + typescript/frontend-marios2/src/lang/en.json | 7 +- typescript/frontend-marios2/src/lang/fr.json | 5 + typescript/frontend-marios2/src/lang/it.json | 5 + 15 files changed, 419 insertions(+), 20 deletions(-) create mode 100644 csharp/App/Backend/DataTypes/TicketCommentMention.cs diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 19fcb4d3c..ab756e82f 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2176,6 +2176,17 @@ public class Controller : ControllerBase CreatedAt = DateTime.UtcNow }); + if (ticket.AssigneeId.HasValue && ticket.AssigneeId.Value != user.Id) + { + var assignee = Db.GetUserById(ticket.AssigneeId); + if (assignee is not null) + _ = Task.Run(async () => + { + try { await assignee.SendTicketAssignedEmail(ticket); } + catch (Exception ex) { Console.WriteLine($"Failed to send ticket assignment email on create: {ex}"); } + }); + } + // Fire-and-forget AI diagnosis var lang = user.Language ?? "en"; TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning(); @@ -2221,6 +2232,40 @@ public class Controller : ControllerBase ActorId = user.Id, CreatedAt = DateTime.UtcNow }); + + var isSolveTransition = ticket.Status == (Int32)TicketStatus.Resolved + && existing.Status != (Int32)TicketStatus.Resolved; + var isReopenTransition = existing.Status == (Int32)TicketStatus.Resolved + && (ticket.Status == (Int32)TicketStatus.InProgress + || ticket.Status == (Int32)TicketStatus.Open); + + if (isSolveTransition) + { + var creator = Db.GetUserById(existing.CreatedByUserId); + if (creator is not null && creator.Id != user.Id) + { + var actorName = user.Name; + _ = Task.Run(async () => + { + try { await creator.SendTicketSolvedEmail(ticket, actorName); } + catch (Exception ex) { Console.WriteLine($"Failed to send ticket solved email: {ex}"); } + }); + } + } + + if (isReopenTransition && existing.AssigneeId.HasValue) + { + var assignee = Db.GetUserById(existing.AssigneeId); + if (assignee is not null && assignee.Id != user.Id) + { + var actorName = user.Name; + _ = Task.Run(async () => + { + try { await assignee.SendTicketReopenedEmail(ticket, actorName); } + catch (Exception ex) { Console.WriteLine($"Failed to send ticket reopened email: {ex}"); } + }); + } + } } if (resolutionAdded) @@ -2253,7 +2298,7 @@ public class Controller : ControllerBase CreatedAt = DateTime.UtcNow }); - if (assignee is not null) + if (assignee is not null && assignee.Id != user.Id) _ = Task.Run(async () => { try { await assignee.SendTicketAssignedEmail(ticket); } @@ -2321,6 +2366,35 @@ public class Controller : ControllerBase ticket.UpdatedAt = DateTime.UtcNow; Db.Update(ticket); + var mentioned = (comment.MentionedUserIds ?? new List()) + .Distinct() + .Where(uid => uid != user.Id) + .ToList(); + + foreach (var uid in mentioned) + { + Db.Create(new TicketCommentMention + { + CommentId = comment.Id, + MentionedUserId = uid, + CreatedAt = DateTime.UtcNow + }); + + var mentionedUser = Db.GetUserById(uid); + if (mentionedUser is null) continue; + + var actorName = user.Name; + var body = comment.Body ?? ""; + var excerpt = body.Length > 300 ? body.Substring(0, 300) + "..." : body; + var ticketRef = ticket; + + _ = Task.Run(async () => + { + try { await mentionedUser.SendTicketMentionEmail(ticketRef, actorName, excerpt); } + catch (Exception ex) { Console.WriteLine($"Failed to send mention email: {ex}"); } + }); + } + return comment; } diff --git a/csharp/App/Backend/DataTypes/Methods/User.cs b/csharp/App/Backend/DataTypes/Methods/User.cs index 6eb13cbaa..8dd43ce5e 100644 --- a/csharp/App/Backend/DataTypes/Methods/User.cs +++ b/csharp/App/Backend/DataTypes/Methods/User.cs @@ -359,4 +359,163 @@ public static class UserMethods return user.SendEmail(subject, body); } + public static Task SendTicketSolvedEmail(this User user, Ticket ticket, String solvedByName) + { + var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}"; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + $"inesco energy – Ticket #{ticket.Id} wurde gelöst", + $"Sehr geehrte/r {user.Name},\n\n" + + $"Ihr Ticket wurde als gelöst markiert von {solvedByName}:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Betreff: {ticket.Subject}\n\n" + + $"Ursache:\n{ticket.RootCause}\n\n" + + $"Lösung:\n{ticket.Solution}\n\n" + + $"Falls das Problem weiterhin besteht, öffnen Sie das Ticket erneut: {ticketLink}\n\n" + + "Mit freundlichen Grüssen\ninesco energy Monitor" + ), + "fr" => ( + $"inesco energy – Le ticket #{ticket.Id} a été résolu", + $"Cher/Chère {user.Name},\n\n" + + $"Votre ticket a été marqué comme résolu par {solvedByName} :\n\n" + + $"Ticket : #{ticket.Id}\n" + + $"Objet : {ticket.Subject}\n\n" + + $"Cause :\n{ticket.RootCause}\n\n" + + $"Solution :\n{ticket.Solution}\n\n" + + $"Si le problème persiste, rouvrez le ticket : {ticketLink}\n\n" + + "Cordialement,\ninesco energy Monitor" + ), + "it" => ( + $"inesco energy – Il ticket #{ticket.Id} è stato risolto", + $"Gentile {user.Name},\n\n" + + $"Il suo ticket è stato contrassegnato come risolto da {solvedByName}:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Oggetto: {ticket.Subject}\n\n" + + $"Causa:\n{ticket.RootCause}\n\n" + + $"Soluzione:\n{ticket.Solution}\n\n" + + $"Se il problema persiste, riaprire il ticket: {ticketLink}\n\n" + + "Cordiali saluti,\ninesco energy Monitor" + ), + _ => ( + $"inesco energy – Ticket #{ticket.Id} has been solved", + $"Dear {user.Name},\n\n" + + $"Your ticket has been marked as solved by {solvedByName}:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Subject: {ticket.Subject}\n\n" + + $"Root cause:\n{ticket.RootCause}\n\n" + + $"Solution:\n{ticket.Solution}\n\n" + + $"If the issue persists, reopen the ticket: {ticketLink}\n\n" + + "Best regards,\ninesco energy Monitor" + ) + }; + + return user.SendEmail(subject, body); + } + + public static Task SendTicketReopenedEmail(this User user, Ticket ticket, String reopenedByName) + { + var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}"; + var priority = (TicketPriority)ticket.Priority; + var category = (TicketCategory)ticket.Category; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + $"inesco energy – Ticket #{ticket.Id} wurde wieder geöffnet", + $"Sehr geehrte/r {user.Name},\n\n" + + $"Ein Ihnen zugewiesenes Ticket wurde von {reopenedByName} wieder geöffnet:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Betreff: {ticket.Subject}\n" + + $"Priorität: {priority}\n" + + $"Kategorie: {category}\n\n" + + $"Öffnen Sie das Ticket hier: {ticketLink}\n\n" + + "Mit freundlichen Grüssen\ninesco energy Monitor" + ), + "fr" => ( + $"inesco energy – Le ticket #{ticket.Id} a été rouvert", + $"Cher/Chère {user.Name},\n\n" + + $"Un ticket qui vous est attribué a été rouvert par {reopenedByName} :\n\n" + + $"Ticket : #{ticket.Id}\n" + + $"Objet : {ticket.Subject}\n" + + $"Priorité : {priority}\n" + + $"Catégorie : {category}\n\n" + + $"Ouvrir le ticket : {ticketLink}\n\n" + + "Cordialement,\ninesco energy Monitor" + ), + "it" => ( + $"inesco energy – Il ticket #{ticket.Id} è stato riaperto", + $"Gentile {user.Name},\n\n" + + $"Un ticket assegnato a lei è stato riaperto da {reopenedByName}:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Oggetto: {ticket.Subject}\n" + + $"Priorità: {priority}\n" + + $"Categoria: {category}\n\n" + + $"Aprire il ticket: {ticketLink}\n\n" + + "Cordiali saluti,\ninesco energy Monitor" + ), + _ => ( + $"inesco energy – Ticket #{ticket.Id} has been reopened", + $"Dear {user.Name},\n\n" + + $"A ticket assigned to you has been reopened by {reopenedByName}:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Subject: {ticket.Subject}\n" + + $"Priority: {priority}\n" + + $"Category: {category}\n\n" + + $"Open the ticket: {ticketLink}\n\n" + + "Best regards,\ninesco energy Monitor" + ) + }; + + return user.SendEmail(subject, body); + } + + public static Task SendTicketMentionEmail(this User user, Ticket ticket, String mentionedByName, String commentExcerpt) + { + var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}"; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + $"inesco energy – Sie wurden in Ticket #{ticket.Id} erwähnt", + $"Sehr geehrte/r {user.Name},\n\n" + + $"{mentionedByName} hat Sie in einem Kommentar zu Ticket #{ticket.Id} erwähnt:\n\n" + + $"Betreff: {ticket.Subject}\n\n" + + $"Kommentar:\n\"{commentExcerpt}\"\n\n" + + $"Ticket öffnen: {ticketLink}\n\n" + + "Mit freundlichen Grüssen\ninesco energy Monitor" + ), + "fr" => ( + $"inesco energy – Vous avez été mentionné dans le ticket #{ticket.Id}", + $"Cher/Chère {user.Name},\n\n" + + $"{mentionedByName} vous a mentionné dans un commentaire sur le ticket #{ticket.Id} :\n\n" + + $"Objet : {ticket.Subject}\n\n" + + $"Commentaire :\n« {commentExcerpt} »\n\n" + + $"Ouvrir le ticket : {ticketLink}\n\n" + + "Cordialement,\ninesco energy Monitor" + ), + "it" => ( + $"inesco energy – È stato menzionato nel ticket #{ticket.Id}", + $"Gentile {user.Name},\n\n" + + $"{mentionedByName} l'ha menzionata in un commento sul ticket #{ticket.Id}:\n\n" + + $"Oggetto: {ticket.Subject}\n\n" + + $"Commento:\n\"{commentExcerpt}\"\n\n" + + $"Aprire il ticket: {ticketLink}\n\n" + + "Cordiali saluti,\ninesco energy Monitor" + ), + _ => ( + $"inesco energy – You were mentioned in ticket #{ticket.Id}", + $"Dear {user.Name},\n\n" + + $"{mentionedByName} mentioned you in a comment on ticket #{ticket.Id}:\n\n" + + $"Subject: {ticket.Subject}\n\n" + + $"Comment:\n\"{commentExcerpt}\"\n\n" + + $"Open the ticket: {ticketLink}\n\n" + + "Best regards,\ninesco energy Monitor" + ) + }; + + return user.SendEmail(subject, body); + } + } \ No newline at end of file diff --git a/csharp/App/Backend/DataTypes/TicketComment.cs b/csharp/App/Backend/DataTypes/TicketComment.cs index a026712b3..df0df19a6 100644 --- a/csharp/App/Backend/DataTypes/TicketComment.cs +++ b/csharp/App/Backend/DataTypes/TicketComment.cs @@ -13,4 +13,6 @@ public class TicketComment public Int64? AuthorId { get; set; } public String Body { get; set; } = ""; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Ignore] public List MentionedUserIds { get; set; } = new(); } diff --git a/csharp/App/Backend/DataTypes/TicketCommentMention.cs b/csharp/App/Backend/DataTypes/TicketCommentMention.cs new file mode 100644 index 000000000..ecb150abd --- /dev/null +++ b/csharp/App/Backend/DataTypes/TicketCommentMention.cs @@ -0,0 +1,12 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public class TicketCommentMention +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64 CommentId { get; set; } + [Indexed] public Int64 MentionedUserId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index c677ae8c0..a6759de3a 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -87,6 +87,7 @@ public static partial class Db public static Boolean Create(TicketComment comment) => Insert(comment); public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis); public static Boolean Create(TicketTimelineEvent ev) => Insert(ev); + public static Boolean Create(TicketCommentMention mention) => Insert(mention); // Document storage public static Boolean Create(Document document) => Insert(document); diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index fe5a8770c..f909b67eb 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -143,6 +143,7 @@ public static partial class Db // Ticket system tables fileConnection.CreateTable(); fileConnection.CreateTable(); + fileConnection.CreateTable(); fileConnection.CreateTable(); fileConnection.CreateTable(); diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 792038882..6bc3ccb64 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -7,15 +7,20 @@ import { CardContent, CardHeader, Chip, + ClickAwayListener, Divider, LinearProgress, + MenuItem, + MenuList, + Paper, + Popper, TextField, Typography } from '@mui/material'; import PersonIcon from '@mui/icons-material/Person'; import SmartToyIcon from '@mui/icons-material/SmartToy'; import AttachFileIcon from '@mui/icons-material/AttachFile'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes'; import DocumentList from 'src/components/DocumentList'; @@ -33,6 +38,7 @@ function CommentThread({ onCommentAdded, adminUsers = [] }: CommentThreadProps) { + const intl = useIntl(); const [body, setBody] = useState(''); const [submitting, setSubmitting] = useState(false); const fileInputRef = useRef(null); @@ -40,6 +46,68 @@ function CommentThread({ const [uploading, setUploading] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + const [mentionedIds, setMentionedIds] = useState([]); + const [mentionQuery, setMentionQuery] = useState(null); + const commentInputRef = useRef(null); + + const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin']; + + const mentionCandidates = mentionQuery === null + ? [] + : adminUsers + .filter((u) => + !MENTION_EXCLUDED_NAMES.includes(u.name) && + u.name.toLowerCase().includes(mentionQuery.toLowerCase()) && + !mentionedIds.includes(u.id) + ) + .slice(0, 8); + + const detectMention = (text: string, cursor: number) => { + const upToCursor = text.slice(0, cursor); + const atIdx = upToCursor.lastIndexOf('@'); + if (atIdx === -1) return null; + const between = upToCursor.slice(atIdx + 1); + if (/\s/.test(between)) return null; + const prevChar = atIdx === 0 ? ' ' : upToCursor[atIdx - 1]; + if (!/\s|^$/.test(prevChar) && atIdx !== 0) return null; + return { atIdx, query: between }; + }; + + const handleBodyChange = (e: React.ChangeEvent) => { + const text = e.target.value; + const cursor = e.target.selectionStart ?? text.length; + setBody(text); + const match = detectMention(text, cursor); + setMentionQuery(match ? match.query : null); + + // Drop mentioned IDs whose display names no longer appear in the body + setMentionedIds((prev) => + prev.filter((uid) => { + const u = adminUsers.find((au) => au.id === uid); + return u ? text.includes(`@${u.name}`) : false; + }) + ); + }; + + const handleSelectMention = (userId: number, userName: string) => { + const input = commentInputRef.current; + const cursor = input?.selectionStart ?? body.length; + const match = detectMention(body, cursor); + if (!match) return; + const before = body.slice(0, match.atIdx); + const after = body.slice(cursor); + const token = `@${userName} `; + const next = `${before}${token}${after}`; + setBody(next); + setMentionedIds((prev) => (prev.includes(userId) ? prev : [...prev, userId])); + setMentionQuery(null); + const caret = before.length + token.length; + setTimeout(() => { + input?.focus(); + input?.setSelectionRange(caret, caret); + }, 0); + }; + const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; const MAX_FILE_SIZE = 25 * 1024 * 1024; @@ -64,7 +132,15 @@ function CommentThread({ try { let commentId: number | undefined; if (body.trim()) { - const res = await axiosConfig.post('/AddTicketComment', { ticketId, body }); + const activeMentionedIds = mentionedIds.filter((uid) => { + const u = adminUsers.find((au) => au.id === uid); + return u ? body.includes(`@${u.name}`) : false; + }); + const res = await axiosConfig.post('/AddTicketComment', { + ticketId, + body, + mentionedUserIds: activeMentionedIds + }); commentId = res.data?.id; } @@ -90,6 +166,8 @@ function CommentThread({ } setBody(''); + setMentionedIds([]); + setMentionQuery(null); setSelectedFiles([]); setRefreshKey((k) => k + 1); onCommentAdded(); @@ -166,10 +244,35 @@ function CommentThread({ multiline minRows={2} maxRows={4} - placeholder="Add a comment..." + placeholder={intl.formatMessage({ + id: 'mentionPlaceholder', + defaultMessage: 'Type @ to mention a user' + })} value={body} - onChange={(e) => setBody(e.target.value)} + onChange={handleBodyChange} + inputRef={commentInputRef} /> + 0} + anchorEl={commentInputRef.current} + placement="top-start" + style={{ zIndex: 1300 }} + > + setMentionQuery(null)}> + + + {mentionCandidates.map((u) => ( + handleSelectMention(u.id, u.name)} + > + {u.name} + + ))} + + + + = { [TicketStatus.Open]: 'Open', [TicketStatus.InProgress]: 'In Progress', [TicketStatus.Escalated]: 'Escalated', - [TicketStatus.Resolved]: 'Resolved', + [TicketStatus.Resolved]: 'Solved', [TicketStatus.Closed]: 'Closed' }; diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx index bb00c7e02..9422415cb 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Alert, @@ -63,9 +63,7 @@ const priorityKeys: Record = { const statusKeys: { value: number; id: string; defaultMessage: string }[] = [ { value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' }, { value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' }, - { value: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated' }, - { value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved' }, - { value: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed' } + { value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved' } ]; function TicketDetailPage() { @@ -90,6 +88,9 @@ function TicketDetailPage() { const [savingDescription, setSavingDescription] = useState(false); const [descriptionSaved, setDescriptionSaved] = useState(false); const [docRefreshKey, setDocRefreshKey] = useState(0); + const [solveGateOpen, setSolveGateOpen] = useState(false); + const rootCauseRef = useRef(null); + const solutionRef = useRef(null); // Custom "Other" editing state const [editCustomSub, setEditCustomSub] = useState(''); @@ -153,9 +154,7 @@ function TicketDetailPage() { newStatus === TicketStatus.Resolved && (!rootCause.trim() || !solution.trim()) ) { - setResolutionError( - 'Root Cause and Solution are required to resolve a ticket.' - ); + setSolveGateOpen(true); return; } setResolutionError(''); @@ -475,6 +474,7 @@ function TicketDetailPage() { error={ !!resolutionError && !rootCause.trim() } + inputRef={rootCauseRef} /> {resolutionSaved && ( @@ -877,6 +878,36 @@ function TicketDetailPage() { + {/* Solve-gate reminder dialog */} + setSolveGateOpen(false)}> + + + + + + + + + + + + +
diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx index ccce78008..36e6d1080 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx @@ -37,9 +37,7 @@ import StatusChip from './StatusChip'; const statusKeys: Record = { [TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' }, [TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' }, - [TicketStatus.Escalated]: { id: 'statusEscalated', defaultMessage: 'Escalated' }, - [TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Resolved' }, - [TicketStatus.Closed]: { id: 'statusClosed', defaultMessage: 'Closed' } + [TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Solved' } }; const priorityKeys: Record = { diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 798a2068a..ecb97852c 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -598,6 +598,11 @@ "statusEscalated": "Eskaliert", "statusResolved": "Gelöst", "statusClosed": "Geschlossen", + "solveGateTitle": "Ursache und Lösung erforderlich", + "solveGateBody": "Um dieses Ticket als gelöst zu markieren, bitte sowohl Ursache als auch Lösung ausfüllen, bevor Sie speichern.", + "solveGateOk": "OK", + "mentionPlaceholder": "@ eingeben, um einen Benutzer zu erwähnen", + "mentionNoResults": "Keine Benutzer gefunden", "priorityCritical": "Kritisch", "priorityHigh": "Hoch", "priorityMedium": "Mittel", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 21de73b3a..5b5e67b91 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -344,8 +344,13 @@ "statusOpen": "Open", "statusInProgress": "In Progress", "statusEscalated": "Escalated", - "statusResolved": "Resolved", + "statusResolved": "Solved", "statusClosed": "Closed", + "solveGateTitle": "Root Cause and Solution required", + "solveGateBody": "To mark this ticket as Solved, please fill in both Root Cause and Solution before saving.", + "solveGateOk": "OK", + "mentionPlaceholder": "Type @ to mention a user", + "mentionNoResults": "No users found", "priorityCritical": "Critical", "priorityHigh": "High", "priorityMedium": "Medium", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index e99292d0e..d6cdc6f62 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -598,6 +598,11 @@ "statusEscalated": "Escaladé", "statusResolved": "Résolu", "statusClosed": "Fermé", + "solveGateTitle": "Cause et solution requises", + "solveGateBody": "Pour marquer ce ticket comme résolu, veuillez renseigner la cause et la solution avant d'enregistrer.", + "solveGateOk": "OK", + "mentionPlaceholder": "Tapez @ pour mentionner un utilisateur", + "mentionNoResults": "Aucun utilisateur trouvé", "priorityCritical": "Critique", "priorityHigh": "Élevée", "priorityMedium": "Moyenne", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 151ba9bf9..59a8c11ce 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -598,6 +598,11 @@ "statusEscalated": "Escalato", "statusResolved": "Risolto", "statusClosed": "Chiuso", + "solveGateTitle": "Causa e soluzione richieste", + "solveGateBody": "Per contrassegnare questo ticket come risolto, compilare sia la causa sia la soluzione prima di salvare.", + "solveGateOk": "OK", + "mentionPlaceholder": "Digita @ per menzionare un utente", + "mentionNoResults": "Nessun utente trovato", "priorityCritical": "Critica", "priorityHigh": "Alta", "priorityMedium": "Media",