Merge branch 'main' into sinexcel_multiinveters_configurtaion
This commit is contained in:
commit
0169576620
|
|
@ -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<Int64>())
|
||||
.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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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<Int64> MentionedUserIds { get; set; } = new();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
|
|
@ -138,6 +143,7 @@ public static partial class Db
|
|||
// Ticket system tables
|
||||
fileConnection.CreateTable<Ticket>();
|
||||
fileConnection.CreateTable<TicketComment>();
|
||||
fileConnection.CreateTable<TicketCommentMention>();
|
||||
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>InnovEnergy.App.GpioTestingProject</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Lib\Devices\GPIORaspberryPI4\GPIORaspberryPI4.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<Import Project="../InnovEnergy.App.props" />
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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":~/
|
||||
|
||||
|
|
@ -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<T>(string filePath, T data)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(data, JsonOptions);
|
||||
File.AppendAllText(filePath, json + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Configuration>(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 );
|
||||
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);
|
||||
|
||||
// Send the reply to the sender's endpoint
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, UInt16> ConvertToModbusRegisters(Object value, String outputType,
|
||||
|
|
@ -488,12 +487,10 @@ public static class Program
|
|||
var modbusData = new Dictionary<String, UInt16>();
|
||||
|
||||
// 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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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");
|
||||
|
|
@ -209,28 +210,17 @@ internal static class Program
|
|||
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<String, Object> jsonDict, String[] keys, String value)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}*/
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
@ -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<T>(string filePath, T data)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(data, JsonOptions);
|
||||
File.AppendAllText(filePath, json + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace InnovEnergy.App.SinexcelCommunication.DataTypes;
|
||||
|
||||
public enum DynamicPricingMode
|
||||
{
|
||||
Disabled,
|
||||
Tou,
|
||||
SpotPrice
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
namespace InnovEnergy.App.SinexcelCommunication.ESS;
|
||||
|
||||
internal enum DesiredAction
|
||||
{
|
||||
OptimizeSelfUse,
|
||||
Charge,
|
||||
Discharge
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
// ----------------------------
|
||||
/// <summary>
|
||||
/// [start, end) window, supports overnight. start==end means disabled.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
|
@ -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 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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Device.Gpio" Version="2.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace GPIORaspberryPI4;
|
||||
|
||||
public interface IDigitalInput : IDisposable
|
||||
{
|
||||
int Pin { get; }
|
||||
bool IsActive { get; }
|
||||
bool Read();
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace GPIORaspberryPI4;
|
||||
|
||||
public interface IRelayOutput : IDisposable
|
||||
{
|
||||
void On();
|
||||
void Off();
|
||||
void Set(bool on);
|
||||
bool IsOn { get; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -175,9 +175,8 @@ public partial class KacoRecord
|
|||
/// Scale factor for battery currents (A_SF).
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -36,42 +36,41 @@ public partial class KacoRecord
|
|||
[HoldingRegister<UInt16>(41074)] private UInt16 _reserved13; // 0xA072
|
||||
[HoldingRegister<UInt16>(41075)] private UInt16 _reserved14; // 0xA073
|
||||
[HoldingRegister<UInt16>(41076)] private UInt16 _reserved15; // 0xA074
|
||||
[HoldingRegister<UInt16>(41077)] private UInt16 _reserved16; // 0xA075
|
||||
|
||||
// Status / error
|
||||
[HoldingRegister<UInt16>(41078)] private UInt16 _stVnd; // 0xA076 – StVnd (enum16, R) PrologState
|
||||
[HoldingRegister<UInt16>(41079)] private UInt16 _stPu; // 0xA077 – StPu (enum16, R) Power Unit State (DSP)
|
||||
[HoldingRegister<UInt16>(41080)] private UInt16 _stPcu; // 0xA078 – StPcu (enum16, R) pcu state
|
||||
[HoldingRegister<UInt16>(41081)] private UInt16 _errPcu; // 0xA079 – ErrPcu (enum16, R) pcu error
|
||||
[HoldingRegister<UInt16>(41077)] private UInt16 _stVnd; // 0xA076 – StVnd (enum16, R) PrologState
|
||||
[HoldingRegister<UInt16>(41078)] private UInt16 _stPu; // 0xA077 – StPu (enum16, R) Power Unit State (DSP)
|
||||
[HoldingRegister<UInt16>(41079)] private UInt16 _stPcu; // 0xA078 – StPcu (enum16, R) pcu state
|
||||
[HoldingRegister<UInt16>(41080)] private UInt16 _errPcu; // 0xA079 – ErrPcu (enum16, R) pcu error
|
||||
|
||||
// Active power ramp parameters
|
||||
[HoldingRegister<UInt16>(41082, writable: true)] private UInt16 _wParamRmpTms; // 0xA07A – WParamRmpTms (uint16, RW, s)
|
||||
[HoldingRegister<UInt16>(41083, writable: true)] private UInt16 _wParamRmpDecTmn; // 0xA07B – WParamRmpDecTmn (uint16, RW, %ref/min)
|
||||
[HoldingRegister<UInt16>(41084, writable: true)] private UInt16 _wParamRmpIncTmn; // 0xA07C – WParamRmpIncTmn (uint16, RW, %ref/min)
|
||||
[HoldingRegister<UInt16>(41081, writable: true)] private UInt16 _wParamRmpTms; // 0xA07A – WParamRmpTms (uint16, RW, s)
|
||||
[HoldingRegister<UInt16>(41082, writable: true)] private UInt16 _wParamRmpDecTmn; // 0xA07B – WParamRmpDecTmn (uint16, RW, %ref/min)
|
||||
[HoldingRegister<UInt16>(41083, writable: true)] private UInt16 _wParamRmpIncTmn; // 0xA07C – WParamRmpIncTmn (uint16, RW, %ref/min)
|
||||
|
||||
[HoldingRegister<UInt16>(41085)] private UInt16 _reserved24; // 0xA07D – Reserved
|
||||
[HoldingRegister<UInt16>(41086)] private UInt16 _reserved25; // 0xA07E – Reserved
|
||||
[HoldingRegister<UInt16>(41084)] private UInt16 _reserved24; // 0xA07D – Reserved
|
||||
[HoldingRegister<UInt16>(41085)] private UInt16 _reserved25; // 0xA07E – Reserved
|
||||
|
||||
[HoldingRegister<UInt16>(41087, writable: true)] private UInt16 _wParamEna; // 0xA07F – WParam_Ena (enum16, RW) WSet_Ena control 0 or 1
|
||||
[HoldingRegister<UInt16>(41086, writable: true)] private UInt16 _wParamEna; // 0xA07F – WParam_Ena (enum16, RW) WSet_Ena control 0 or 1
|
||||
|
||||
// Reactive power ramp parameters
|
||||
[HoldingRegister<UInt16>(41088, writable: true)] private UInt16 _varParamRmpTms; // 0xA080 – VarParamRmpTms (uint16, RW, s)
|
||||
[HoldingRegister<UInt16>(41089, writable: true)] private UInt16 _varParamRmpDecTmn; // 0xA081 – VarParamRmpDecTmn (uint16, RW, %ref/min)
|
||||
[HoldingRegister<UInt16>(41090, writable: true)] private UInt16 _varParamRmpIncTmn; // 0xA082 – VarParamRmpIncTmn (uint16, RW, %ref/min)
|
||||
[HoldingRegister<UInt16>(41087, writable: true)] private UInt16 _varParamRmpTms; // 0xA080 – VarParamRmpTms (uint16, RW, s)
|
||||
[HoldingRegister<UInt16>(41088, writable: true)] private UInt16 _varParamRmpDecTmn; // 0xA081 – VarParamRmpDecTmn (uint16, RW, %ref/min)
|
||||
[HoldingRegister<UInt16>(41089, writable: true)] private UInt16 _varParamRmpIncTmn; // 0xA082 – VarParamRmpIncTmn (uint16, RW, %ref/min)
|
||||
|
||||
[HoldingRegister<UInt16>(41091)] private UInt16 _reserved30; // 0xA083 – Reserved
|
||||
[HoldingRegister<UInt16>(41092)] private UInt16 _reserved31; // 0xA084 – Reserved
|
||||
[HoldingRegister<UInt16>(41090)] private UInt16 _reserved30; // 0xA083 – Reserved
|
||||
[HoldingRegister<UInt16>(41091)] private UInt16 _reserved31; // 0xA084 – Reserved
|
||||
|
||||
[HoldingRegister<UInt16>(41093, writable: true)] private UInt16 _varParamEna; // 0xA085 – VarParam_Ena (enum16, RW) Enumerated valued. Percent limit VAr enable/disable control.
|
||||
[HoldingRegister<UInt16>(41092, writable: true)] private UInt16 _varParamEna; // 0xA085 – VarParam_Ena (enum16, RW) Enumerated valued. Percent limit VAr enable/disable control.
|
||||
|
||||
// Measurements (read-only)
|
||||
[HoldingRegister<UInt16>(41094)] private UInt16 _phaseVoltageAN; // 0xA086 – PhVphA (uint16, R, V, V_SF)
|
||||
[HoldingRegister<UInt16>(41095)] private UInt16 _phaseVoltageBN; // 0xA087 – PhVphB (uint16, R, V, V_SF)
|
||||
[HoldingRegister<UInt16>(41096)] private UInt16 _phaseVoltageCN; // 0xA088 – PhVphC (uint16, R, V, V_SF)
|
||||
[HoldingRegister<UInt16>(41093)] private UInt16 _phaseVoltageAN; // 0xA086 – PhVphA (uint16, R, V, V_SF)
|
||||
[HoldingRegister<UInt16>(41094)] private UInt16 _phaseVoltageBN; // 0xA087 – PhVphB (uint16, R, V, V_SF)
|
||||
[HoldingRegister<UInt16>(41095)] private UInt16 _phaseVoltageCN; // 0xA088 – PhVphC (uint16, R, V, V_SF)
|
||||
|
||||
[HoldingRegister<Int16> (41097)] private Int16 _activePowerW; // 0xA089 – W (int16, R, W, W_SF)
|
||||
[HoldingRegister<Int16> (41098)] private Int16 _reactivePowerVar; // 0xA08A – VAR (int16, R, var, Var_SF)
|
||||
[HoldingRegister<Int16> (41099)] private Int16 _lineFrequencyHz; // 0xA08B – Hz (int16, R, Hz, Hz_SF)
|
||||
[HoldingRegister<Int16> (41096)] private Int16 _activePowerW; // 0xA089 – W (int16, R, W, W_SF)
|
||||
[HoldingRegister<Int16> (41097)] private Int16 _reactivePowerVar; // 0xA08A – VAR (int16, R, var, Var_SF)
|
||||
[HoldingRegister<Int16> (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<Int16>(41109)] private Int16 _rmpTmsSf; // 0xA0F5 – RmpTms_SF
|
||||
// Scale factor for increment and decrement ramps.
|
||||
[HoldingRegister<Int16>(41110)] private Int16 _rmpIncDecSf; // 0xA0F6 – RmpIncDec_SF
|
||||
|
||||
[HoldingRegister<Int16>(41112)] private Int16 _wSf; // W_SF
|
||||
// Header
|
||||
[HoldingRegister<UInt16>(41115)] private UInt16 _battCharId; // ID = 64202
|
||||
[HoldingRegister<UInt16>(41116)] private UInt16 _battCharLength; // L = 6 + (RBCount * 8)
|
||||
|
|
|
|||
|
|
@ -142,6 +142,20 @@ public partial class WITGrowatRecord
|
|||
|
||||
}
|
||||
|
||||
|
||||
public UInt16 ExportLimitationEnabled
|
||||
{
|
||||
get => _ExportLimitationEnabled;
|
||||
set => _ExportLimitationEnabled = value;
|
||||
}
|
||||
|
||||
|
||||
public Int16 ExportLimitationPowerRate
|
||||
{
|
||||
get => _ExportLimitationPowerRate;
|
||||
set => _ExportLimitationPowerRate = value;
|
||||
}
|
||||
|
||||
public UInt16 EmsCommunicationFailureTime
|
||||
{
|
||||
get => _EmsCommunicationFailureTime;
|
||||
|
|
|
|||
|
|
@ -166,6 +166,10 @@ public partial class WITGrowatRecord
|
|||
[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(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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -440,24 +440,24 @@ public partial class SinexcelRecord
|
|||
[HoldingRegister<UInt32>(12384, writable: true)] private UInt32 _outputVoltageAdjustmentFactor; // 0x3060
|
||||
[HoldingRegister<UInt32>(12386, writable: true)] private UInt32 _setValueBatteryUndervoltage1; // 0x3062
|
||||
[HoldingRegister<UInt32>(12388, writable: true)] private UInt32 _inverterPowerLimit; // 0x3064
|
||||
[HoldingRegister<UInt32>(12400, writable: true)] private UInt32 _battery2Capacity; // 0x30B0
|
||||
[HoldingRegister<UInt32>(12402, writable: true)] private UInt32 _maxChargingCurrentBattery2; // 0x30B2
|
||||
[HoldingRegister<UInt32>(12404, writable: true)] private UInt32 _maxDischargingCurrentBattery2; // 0x30B4
|
||||
[HoldingRegister<UInt32>(12406, writable: true)] private UInt32 _battery2RatedVoltage; // 0x30B6
|
||||
[HoldingRegister<UInt32>(12408, writable: true)] private UInt32 _battery2MinSoc; // 0x30B8
|
||||
[HoldingRegister<UInt32>(12410, writable: true)] private UInt32 _battery2OverVoltageSetting; // 0x30BA
|
||||
[HoldingRegister<UInt32>(12412, writable: true)] private UInt32 _battery2UnderVoltageSetpoint; // 0x30BC
|
||||
[HoldingRegister<UInt32>(12464, writable: true)] private UInt32 _battery2Capacity; // 0x30B0
|
||||
[HoldingRegister<UInt32>(12466, writable: true)] private UInt32 _maxChargingCurrentBattery2; // 0x30B2
|
||||
[HoldingRegister<UInt32>(12468, writable: true)] private UInt32 _maxDischargingCurrentBattery2; // 0x30B4
|
||||
[HoldingRegister<UInt32>(12470, writable: true)] private UInt32 _battery2RatedVoltage; // 0x30B6
|
||||
[HoldingRegister<UInt32>(12472, writable: true)] private UInt32 _battery2MinSoc; // 0x30B8
|
||||
[HoldingRegister<UInt32>(12474, writable: true)] private UInt32 _battery2OverVoltageSetting; // 0x30BA
|
||||
[HoldingRegister<UInt32>(12476, writable: true)] private UInt32 _battery2UnderVoltageSetpoint; // 0x30BC
|
||||
//
|
||||
[HoldingRegister<UInt32>(12414, writable: true)] private UInt32 _singleOrParallelMachine; // 0x30BE
|
||||
[HoldingRegister<UInt32>(12416, writable: true)] private UInt32 _numberOfSystemModules; // 0x30C0
|
||||
[HoldingRegister<UInt32>(12418, writable: true)] private UInt32 _parallelModuleMachineNumber; // 0x30C2
|
||||
[HoldingRegister<UInt32>(12420, writable: true)] private UInt32 _accreditedCountries; // 0x30C4
|
||||
/* [HoldingRegister<UInt32>(12478, writable: true)] private UInt32 _singleOrParallelMachine; // 0x30BE // to be verified, writing not allowed
|
||||
[HoldingRegister<UInt32>(12480, writable: true)] private UInt32 _numberOfSystemModules; // 0x30C0 // to be verified, writing not allowed
|
||||
[HoldingRegister<UInt32>(12482, writable: true)] private UInt32 _parallelModuleMachineNumber; // 0x30C2 // to be verified, writing not allowed
|
||||
[HoldingRegister<UInt32>(12484, writable: true)] private UInt32 _accreditedCountries; */ // 0x30C4 // to be verified, writing not allowed
|
||||
|
||||
[HoldingRegister<UInt32>(12618, writable: true)] private UInt32 _battery1BackupSOC; // 0x314A
|
||||
[HoldingRegister<UInt32>(12620, writable: true)] private UInt32 _battery2BackupSOC; // 0x314C
|
||||
|
||||
[HoldingRegister<UInt32>(12746, writable: true)] private UInt32 _enableGridExport; // 0x314A
|
||||
[HoldingRegister<UInt32>(12748, writable: true)] private UInt32 _powerGridExportLimit; // 0x314C
|
||||
[HoldingRegister<UInt32>(12746, writable: true)] private UInt32 _enableGridExport; // 0x31CA
|
||||
[HoldingRegister<UInt32>(12748, writable: true)] private UInt32 _powerGridExportLimit; // 0x31CC
|
||||
|
||||
|
||||
// ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -762,6 +762,28 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
|
||||
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
||||
<FormattedMessage id="dataCollectionEnabled" defaultMessage="Data Collection" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
name="dataCollectionEnabled"
|
||||
value={formValues.dataCollectionEnabled === false ? 'no' : 'yes'}
|
||||
onChange={(e) =>
|
||||
setFormValues({
|
||||
...formValues,
|
||||
dataCollectionEnabled: e.target.value === 'yes'
|
||||
})
|
||||
}
|
||||
inputProps={{ readOnly: !canEdit }}
|
||||
>
|
||||
<MenuItem value="yes"><FormattedMessage id="yes" defaultMessage="Yes" /></MenuItem>
|
||||
<MenuItem value="no"><FormattedMessage id="no" defaultMessage="No" /></MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
|
||||
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
||||
|
|
@ -1020,11 +1042,30 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
{Array.from({ length: batteryCount }, (_, batIdx) => (
|
||||
<TextField
|
||||
key={`bat-${invIdx}-${clIdx}-${batIdx}`}
|
||||
id={`bat-${invIdx}-${clIdx}-${batIdx}`}
|
||||
label={intl.formatMessage({ id: 'batteryNSerialNumber', defaultMessage: 'Battery {n} Serial Number' }, { n: batIdx + 1 })}
|
||||
value={batterySnTree[invIdx]?.[clIdx]?.[batIdx] || ''}
|
||||
onChange={(e) =>
|
||||
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' : ''}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@ export const INSTALLATION_PRESETS: Record<number, Record<string, PresetConfig>>
|
|||
'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]],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '40vh'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Live view coming soon
|
||||
</Typography>
|
||||
</Container>
|
||||
<TopologySodistoreHome
|
||||
values={values}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
batteryClusterNumber={props.current_installation.batteryClusterNumber}
|
||||
></TopologySodistoreHome>
|
||||
) : (
|
||||
<Topology
|
||||
values={values}
|
||||
|
|
|
|||
|
|
@ -1145,7 +1145,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
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ export const getChartOptions = (
|
|||
curve: 'smooth',
|
||||
width: 2
|
||||
},
|
||||
grid: {
|
||||
padding: {
|
||||
top: 30
|
||||
}
|
||||
},
|
||||
yaxis:
|
||||
type === 'dailyoverview'
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -209,46 +209,60 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
marginLeft: '15px'
|
||||
}}
|
||||
>
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
{installation.dataCollectionEnabled === false ? (
|
||||
<div
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
borderRadius: '50%'
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'grey'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<>
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{installation.testingMode && (
|
||||
<BuildIcon
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
const [values, setValues] = useState<JSONRecordData | null>(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 ? (
|
||||
<CancelIcon
|
||||
{dataCollectionDisabled ? (
|
||||
<div
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
borderRadius: '50%'
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'grey'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<>
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.current_installation.testingMode && (
|
||||
<BuildIcon
|
||||
|
|
@ -521,7 +536,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
}
|
||||
/>
|
||||
|
||||
{currentUser.userType !== UserType.client && (
|
||||
{currentUser.userType !== UserType.client && !dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.log}
|
||||
element={
|
||||
|
|
@ -534,19 +549,21 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={routes.live}
|
||||
element={
|
||||
<TopologySodistoreHome
|
||||
values={values}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
batteryClusterNumber={props.current_installation.batteryClusterNumber}
|
||||
></TopologySodistoreHome>
|
||||
}
|
||||
/>
|
||||
{!dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.live}
|
||||
element={
|
||||
<TopologySodistoreHome
|
||||
values={values}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
batteryClusterNumber={props.current_installation.batteryClusterNumber}
|
||||
></TopologySodistoreHome>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentUser.userType !== UserType.client && (
|
||||
{currentUser.userType !== UserType.client && !dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.batteryview + '/*'}
|
||||
element={
|
||||
|
|
@ -573,7 +590,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{currentUser.userType == UserType.admin && (
|
||||
{currentUser.userType == UserType.admin && !dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.configuration}
|
||||
element={
|
||||
|
|
@ -600,21 +617,23 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
|||
/>
|
||||
)} */}
|
||||
|
||||
<Route
|
||||
path={routes.overview}
|
||||
element={
|
||||
<Overview
|
||||
s3Credentials={s3Credentials}
|
||||
id={props.current_installation.id}
|
||||
device={props.current_installation.device}
|
||||
product={props.current_installation.product}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.overview}
|
||||
element={
|
||||
<Overview
|
||||
s3Credentials={s3Credentials}
|
||||
id={props.current_installation.id}
|
||||
device={props.current_installation.device}
|
||||
product={props.current_installation.product}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{props.current_installation.device !== 3 && (
|
||||
{props.current_installation.device !== 3 && !dataCollectionDisabled && (
|
||||
<Route
|
||||
path={routes.report}
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
|||
?? Array(inverterNum).fill(props.values.Config.BatteriesCount || 1);
|
||||
return {
|
||||
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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
vpnIp: '',
|
||||
installationModel: '',
|
||||
externalEms: 'No',
|
||||
dataCollectionEnabled: true,
|
||||
...(isSodistorePro ? { device: 4 } : {}),
|
||||
});
|
||||
const [inverterCount, setInverterCount] = useState('');
|
||||
|
|
@ -249,6 +250,46 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
|||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FormControl
|
||||
fullWidth
|
||||
sx={{
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
width: 390
|
||||
}}
|
||||
>
|
||||
<InputLabel
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="dataCollectionEnabled"
|
||||
defaultMessage="Data Collection"
|
||||
/>
|
||||
</InputLabel>
|
||||
<Select
|
||||
name="dataCollectionEnabled"
|
||||
value={formValues.dataCollectionEnabled ? 'yes' : 'no'}
|
||||
onChange={(e) =>
|
||||
setFormValues({
|
||||
...formValues,
|
||||
dataCollectionEnabled: e.target.value === 'yes'
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="yes">
|
||||
<FormattedMessage id="yes" defaultMessage="Yes" />
|
||||
</MenuItem>
|
||||
<MenuItem value="no">
|
||||
<FormattedMessage id="no" defaultMessage="No" />
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
</Box>
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -275,6 +275,12 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
const isGrowatt = currentInstallation?.device === 3
|
||||
|| (installations.length === 1 && installations[0].device === 3);
|
||||
|
||||
// When data collection is disabled, only navigation, info, history, tickets, documents remain.
|
||||
const dataCollectionDisabled =
|
||||
currentInstallation?.dataCollectionEnabled === false
|
||||
|| (installations.length === 1 && installations[0].dataCollectionEnabled === false);
|
||||
const allowedWhenDisabled = ['list', 'tree', 'information', 'history', 'installationTickets', 'documents'];
|
||||
|
||||
const tabs = inInstallationView && currentUser.userType == UserType.admin
|
||||
? [
|
||||
{
|
||||
|
|
@ -471,6 +477,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
>
|
||||
{tabs
|
||||
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||
.filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value))
|
||||
.map((tab) => (
|
||||
<Tab
|
||||
key={tab.value}
|
||||
|
|
@ -544,6 +551,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
>
|
||||
{singleInstallationTabs
|
||||
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||
.filter((tab) => !dataCollectionDisabled || allowedWhenDisabled.includes(tab.value))
|
||||
.map((tab) => (
|
||||
<Tab
|
||||
key={tab.value}
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(null);
|
||||
|
|
@ -40,6 +46,68 @@ function CommentThread({
|
|||
const [uploading, setUploading] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const [mentionedIds, setMentionedIds] = useState<number[]>([]);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const commentInputRef = useRef<HTMLInputElement | null>(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<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
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}
|
||||
/>
|
||||
<Popper
|
||||
open={mentionQuery !== null && mentionCandidates.length > 0}
|
||||
anchorEl={commentInputRef.current}
|
||||
placement="top-start"
|
||||
style={{ zIndex: 1300 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setMentionQuery(null)}>
|
||||
<Paper elevation={4} sx={{ minWidth: 200, maxHeight: 240, overflowY: 'auto' }}>
|
||||
<MenuList dense>
|
||||
{mentionCandidates.map((u) => (
|
||||
<MenuItem
|
||||
key={u.id}
|
||||
onClick={() => handleSelectMention(u.id, u.name)}
|
||||
>
|
||||
{u.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, alignSelf: 'flex-end' }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
|
|||
|
|
@ -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<number | ''>('');
|
||||
const [selectedDevice, setSelectedDevice] = useState<number | ''>('');
|
||||
|
|
@ -73,6 +75,8 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
useState<Installation | null>(null);
|
||||
const [loadingInstallations, setLoadingInstallations] = useState(false);
|
||||
const [priority, setPriority] = useState<number>(TicketPriority.Medium);
|
||||
const [assigneeId, setAssigneeId] = useState<number | ''>('');
|
||||
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
|
||||
const [category, setCategory] = useState<number>(TicketCategory.Hardware);
|
||||
const [subCategory, setSubCategory] = useState<number>(
|
||||
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 }:
|
|||
)}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth margin="dense" required error={assigneeId === ''}>
|
||||
<InputLabel>
|
||||
<FormattedMessage id="assignee" defaultMessage="Assignee" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={assigneeId}
|
||||
label="Assignee"
|
||||
onChange={(e) =>
|
||||
setAssigneeId(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
>
|
||||
{adminUsers
|
||||
.filter((u) => {
|
||||
const name = (u.name ?? '').toLowerCase();
|
||||
return (
|
||||
!name.includes('inesco energy master admin') &&
|
||||
!name.includes('paal myhre')
|
||||
);
|
||||
})
|
||||
.map((u) => (
|
||||
<MenuItem key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="priority" defaultMessage="Priority" />
|
||||
|
|
|
|||
|
|
@ -32,9 +32,7 @@ const statusCountKeys: {
|
|||
}[] = [
|
||||
{ status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' },
|
||||
{ status: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress', color: '#ed6c02' },
|
||||
{ status: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated', color: '#9c27b0' },
|
||||
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved', color: '#2e7d32' },
|
||||
{ status: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed', color: '#757575' }
|
||||
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved', color: '#2e7d32' }
|
||||
];
|
||||
|
||||
function InstallationTicketsTab({ installationId }: Props) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const statusLabels: Record<number, string> = {
|
|||
[TicketStatus.Open]: 'Open',
|
||||
[TicketStatus.InProgress]: 'In Progress',
|
||||
[TicketStatus.Escalated]: 'Escalated',
|
||||
[TicketStatus.Resolved]: 'Resolved',
|
||||
[TicketStatus.Resolved]: 'Solved',
|
||||
[TicketStatus.Closed]: 'Closed'
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<number, { id: string; defaultMessage: string }> = {
|
|||
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<HTMLInputElement | null>(null);
|
||||
const solutionRef = useRef<HTMLInputElement | null>(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}
|
||||
/>
|
||||
<TextField
|
||||
label={
|
||||
|
|
@ -491,6 +491,7 @@ function TicketDetailPage() {
|
|||
error={
|
||||
!!resolutionError && !solution.trim()
|
||||
}
|
||||
inputRef={solutionRef}
|
||||
/>
|
||||
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
||||
{resolutionSaved && (
|
||||
|
|
@ -591,11 +592,19 @@ function TicketDetailPage() {
|
|||
/>
|
||||
</em>
|
||||
</MenuItem>
|
||||
{adminUsers.map((u) => (
|
||||
<MenuItem key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
{adminUsers
|
||||
.filter((u) => {
|
||||
const name = (u.name ?? '').toLowerCase();
|
||||
return (
|
||||
!name.includes('inesco energy master admin') &&
|
||||
!name.includes('paal myhre')
|
||||
);
|
||||
})
|
||||
.map((u) => (
|
||||
<MenuItem key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
|
|
@ -869,6 +878,36 @@ function TicketDetailPage() {
|
|||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Solve-gate reminder dialog */}
|
||||
<Dialog open={solveGateOpen} onClose={() => setSolveGateOpen(false)}>
|
||||
<DialogTitle>
|
||||
<FormattedMessage
|
||||
id="solveGateTitle"
|
||||
defaultMessage="Root Cause and Solution required"
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
<FormattedMessage
|
||||
id="solveGateBody"
|
||||
defaultMessage="To mark this ticket as Solved, please fill in both Root Cause and Solution before saving."
|
||||
/>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
setSolveGateOpen(false);
|
||||
if (!rootCause.trim()) rootCauseRef.current?.focus();
|
||||
else solutionRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="solveGateOk" defaultMessage="OK" />
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
</Container>
|
||||
<Footer />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ import StatusChip from './StatusChip';
|
|||
const statusKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||
[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<number, { id: string; defaultMessage: string }> = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -65,6 +65,15 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
|
|||
const totalGridPower: number =
|
||||
inv?.TotalGridPower ?? inv?.GridPower ?? 0;
|
||||
|
||||
const avgBatterySoc: number = hasDevices
|
||||
? (inv?.AvgBatterySoc ?? 0)
|
||||
: (growattActiveIndices.length
|
||||
? growattActiveIndices.reduce(
|
||||
(sum, i) => sum + (Number(inv?.[`Battery${i}Soc`]) || 0),
|
||||
0
|
||||
) / growattActiveIndices.length
|
||||
: 0);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
|
||||
<Grid container>
|
||||
|
|
@ -255,42 +264,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 (
|
||||
<TopologyColumn
|
||||
key={index + 1}
|
||||
centerBox={{
|
||||
title: `Battery C${index + 1}`,
|
||||
data: inv
|
||||
? [
|
||||
{ value: soc, unit: '%' },
|
||||
{ value: power, unit: 'W' }
|
||||
]
|
||||
: undefined,
|
||||
connected: true
|
||||
}}
|
||||
isFirst={false}
|
||||
isLast={true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<TopologyColumn
|
||||
centerBox={{
|
||||
title: 'Battery',
|
||||
data: inv
|
||||
? [
|
||||
{ value: avgBatterySoc, unit: '%' },
|
||||
{ value: totalBatteryPower, unit: 'W' }
|
||||
]
|
||||
: undefined,
|
||||
connected: true
|
||||
}}
|
||||
isFirst={false}
|
||||
isLast={true}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -257,14 +257,11 @@ function TopologyBox(props: TopologyBoxProps) {
|
|||
}}
|
||||
>
|
||||
{props.data.map((boxData, index) => {
|
||||
const formatted = formatPower(boxData.value, boxData.unit);
|
||||
return (
|
||||
<Typography key={index}>
|
||||
{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}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -160,50 +160,64 @@ function CustomTreeItem(props: CustomTreeItemProps) {
|
|||
|
||||
{props.node.type === 'Installation' && (
|
||||
<div>
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
{(props.node as any).dataCollectionEnabled === false ? (
|
||||
<div
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
marginLeft: '30px',
|
||||
marginTop: '30px'
|
||||
marginLeft: '17px',
|
||||
backgroundColor: 'grey'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<>
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
style={{
|
||||
width: '23px',
|
||||
height: '23px',
|
||||
color: 'red',
|
||||
borderRadius: '50%',
|
||||
marginLeft: '30px',
|
||||
marginTop: '30px'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d',
|
||||
marginLeft: '22px',
|
||||
marginTop: '30px'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{status === -2 ? (
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{
|
||||
color: '#f7b34d',
|
||||
marginLeft: '22px',
|
||||
marginTop: '30px'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
marginLeft: '17px',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
marginLeft: '17px',
|
||||
backgroundColor:
|
||||
status === 2
|
||||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -584,6 +587,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?",
|
||||
|
|
@ -604,6 +608,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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -332,6 +335,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?",
|
||||
|
|
@ -350,8 +354,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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -584,6 +587,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 ?",
|
||||
|
|
@ -604,6 +608,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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -584,6 +587,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?",
|
||||
|
|
@ -604,6 +608,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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue