Merge remote-tracking branch 'origin/main'

This commit is contained in:
atef 2026-02-27 11:16:56 +01:00
commit d81ef73bcf
57 changed files with 17430 additions and 710 deletions

View File

@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.205.17" />
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
<PackageReference Include="Microsoft.AspNet.Identity.Core" Version="2.2.3" />
@ -43,6 +44,15 @@
<None Update="Resources/s3cmd.py">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Resources/AlarmTranslations.*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Resources/AlarmNames.de.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update=".env">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Remove="DbBackups\db-1698326303.sqlite" />
<None Remove="DbBackups\db-1698327045.sqlite" />
<None Remove="DbBackups\db-1699453468.sqlite" />

View File

@ -5,6 +5,7 @@ using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.App.Backend.Relations;
using InnovEnergy.App.Backend.Services;
using InnovEnergy.App.Backend.Websockets;
using InnovEnergy.Lib.Utils;
using Microsoft.AspNetCore.Mvc;
@ -362,6 +363,38 @@ public class Controller : ControllerBase
return user.AccessibleInstallations().ToList();
}
[HttpGet(nameof(GetDirectInstallationAccessForUser))]
public ActionResult<IEnumerable<Object>> GetDirectInstallationAccessForUser(Int64 userId, Token authToken)
{
var sessionUser = Db.GetSession(authToken)?.User;
if (sessionUser == null)
return Unauthorized();
var user = Db.GetUserById(userId);
if (user == null)
return Unauthorized();
return user.DirectlyAccessibleInstallations()
.Select(i => new { i.Id, i.Name })
.ToList<Object>();
}
[HttpGet(nameof(GetDirectFolderAccessForUser))]
public ActionResult<IEnumerable<Object>> GetDirectFolderAccessForUser(Int64 userId, Token authToken)
{
var sessionUser = Db.GetSession(authToken)?.User;
if (sessionUser == null)
return Unauthorized();
var user = Db.GetUserById(userId);
if (user == null)
return Unauthorized();
return user.DirectlyAccessibleFolders()
.Select(f => new { f.Id, f.Name })
.ToList<Object>();
}
[HttpGet(nameof(GetUsersWithInheritedAccessToInstallation))]
public ActionResult<IEnumerable<Object>> GetUsersWithInheritedAccessToInstallation(Int64 id, Token authToken)
{
@ -741,6 +774,188 @@ public class Controller : ControllerBase
: Unauthorized();
}
/// <summary>
/// Returns an AI-generated diagnosis for a single error/alarm description.
/// Responses are cached in memory — repeated calls for the same error code
/// do not hit Mistral again.
/// </summary>
[HttpGet(nameof(DiagnoseError))]
public async Task<ActionResult<DiagnosticResponse>> DiagnoseError(Int64 installationId, string errorDescription, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation))
return Unauthorized();
// AI diagnostics are scoped to SodistoreHome and SodiStoreMax only
if (installation.Product != (int)ProductType.SodioHome &&
installation.Product != (int)ProductType.SodiStoreMax)
return BadRequest("AI diagnostics not available for this product.");
var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription, user.Language ?? "en");
if (result is null)
return NoContent(); // no diagnosis available (not in knowledge base, no API key)
return result;
}
/// <summary>
/// Test endpoint for AlarmKnowledgeBase - no authentication required.
/// Tests multiple Sinexcel and Growatt alarms to verify the knowledge base works.
/// Remove this endpoint in production if not needed.
/// </summary>
[HttpGet(nameof(TestAlarmKnowledgeBase))]
public ActionResult TestAlarmKnowledgeBase()
{
var testCases = new[]
{
// Sinexcel alarms (keys match SinexcelRecord.Api.cs property names)
"FanFault",
"AbnormalGridVoltage",
"Battery1NotConnected",
"InverterPowerTubeFault",
"IslandProtection",
// Growatt alarms (keys match GrowattWarningCode/GrowattErrorCode enum names)
"NoUtilityGrid",
"BatteryCommunicationFailure",
"BmsFault",
"OverTemperature",
"AFCI Fault",
// Unknown alarm (should return null - would call Mistral)
"Some unknown alarm XYZ123"
};
var results = new List<object>();
foreach (var alarm in testCases)
{
var diagnosis = AlarmKnowledgeBase.TryGetDiagnosis(alarm);
results.Add(new
{
Alarm = alarm,
FoundInKnowledgeBase = diagnosis != null,
Explanation = diagnosis?.Explanation ?? "NOT FOUND - Would call Mistral API",
CausesCount = diagnosis?.Causes.Count ?? 0,
NextStepsCount = diagnosis?.NextSteps.Count ?? 0
});
}
return Ok(new
{
TestTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
TotalTests = testCases.Length,
FoundInKnowledgeBase = results.Count(r => ((dynamic)r).FoundInKnowledgeBase),
WouldCallMistral = results.Count(r => !((dynamic)r).FoundInKnowledgeBase),
Results = results
});
}
/// <summary>
/// Test endpoint for the full AI diagnostic flow (knowledge base + Mistral API).
/// No auth required. Remove before production.
/// Usage: GET /api/TestDiagnoseError?errorDescription=SomeAlarm
/// </summary>
[HttpGet(nameof(TestDiagnoseError))]
public async Task<ActionResult> TestDiagnoseError(string errorDescription = "AbnormalGridVoltage", string language = "en")
{
// 1. Try static lookup (KB for English, pre-generated translations for others)
var staticResult = DiagnosticService.TryGetTranslation(errorDescription, language);
if (staticResult is not null)
{
return Ok(new
{
Source = "KnowledgeBase",
Alarm = errorDescription,
MistralEnabled = DiagnosticService.IsEnabled,
staticResult.Explanation,
staticResult.Causes,
staticResult.NextSteps
});
}
// 2. If not found, try Mistral with the correct language
if (!DiagnosticService.IsEnabled)
return Ok(new { Source = "None", Alarm = errorDescription, Message = "Not in knowledge base and Mistral API key not configured." });
var aiResult = await DiagnosticService.TestCallMistralAsync(errorDescription, language);
if (aiResult is null)
return Ok(new { Source = "MistralFailed", Alarm = errorDescription, Message = "Mistral API call failed or returned empty." });
return Ok(new
{
Source = "MistralAI",
Alarm = errorDescription,
aiResult.Explanation,
aiResult.Causes,
aiResult.NextSteps
});
}
// ── Weekly Performance Report ──────────────────────────────────────
/// <summary>
/// Generates a weekly performance report from an Excel file in tmp_report/{installationId}.xlsx
/// Returns JSON with daily data, weekly totals, ratios, and AI insight.
/// </summary>
[HttpGet(nameof(GetWeeklyReport))]
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(Int64 installationId, Token authToken, string? language = null)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation))
return Unauthorized();
var filePath = Environment.CurrentDirectory + "/tmp_report/" + installationId + ".xlsx";
if (!System.IO.File.Exists(filePath))
return NotFound($"No report data file found. Please place the Excel export at: tmp_report/{installationId}.xlsx");
try
{
var lang = language ?? user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
return Ok(report);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[GetWeeklyReport] Error: {ex.Message}");
return BadRequest($"Failed to generate report: {ex.Message}");
}
}
/// <summary>
/// Sends the weekly report as a formatted HTML email.
/// </summary>
[HttpPost(nameof(SendWeeklyReportEmail))]
public async Task<ActionResult> SendWeeklyReportEmail(Int64 installationId, string emailAddress, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation))
return Unauthorized();
try
{
var lang = user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang);
return Ok(new { message = $"Report sent to {emailAddress}" });
}
catch (Exception ex)
{
Console.Error.WriteLine($"[SendWeeklyReportEmail] Error: {ex.Message}");
return BadRequest($"Failed to send report: {ex.Message}");
}
}
[HttpPut(nameof(UpdateFolder))]
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
{
@ -1072,6 +1287,104 @@ public class Controller : ControllerBase
return Redirect($"https://monitor.inesco.energy/?username={user.Email}&reset=true"); // TODO: move to settings file
}
// ── Alarm Review Campaign ────────────────────────────────────────────────
[HttpPost(nameof(SendTestAlarmReview))]
public async Task<ActionResult> SendTestAlarmReview()
{
await AlarmReviewService.SendTestBatchAsync();
return Ok(new { message = "Test review email sent to liu@inesco.energy. Check your inbox." });
}
[HttpPost(nameof(StartAlarmReviewCampaign))]
public ActionResult StartAlarmReviewCampaign()
{
AlarmReviewService.StartCampaign();
return Ok(new { message = "Alarm review campaign started." });
}
[HttpPost(nameof(StopAlarmReviewCampaign))]
public ActionResult StopAlarmReviewCampaign()
{
AlarmReviewService.StopCampaign();
return Ok(new { message = "Campaign paused — progress preserved. Use ResumeAlarmReviewCampaign to restart timers." });
}
[HttpPost(nameof(ResumeAlarmReviewCampaign))]
public ActionResult ResumeAlarmReviewCampaign()
{
AlarmReviewService.ResumeCampaign();
return Ok(new { message = "Campaign resumed — timers restarted from existing progress." });
}
[HttpPost(nameof(ResetAlarmReviewCampaign))]
public ActionResult ResetAlarmReviewCampaign()
{
AlarmReviewService.ResetCampaign();
return Ok(new { message = "Campaign fully reset — all progress deleted. Use StartAlarmReviewCampaign to begin again." });
}
[HttpGet(nameof(CorrectAlarm))]
public ActionResult CorrectAlarm(int batch, string key)
{
var html = AlarmReviewService.GetCorrectionPage(batch, key);
return Content(html, "text/html");
}
[HttpPost(nameof(ApplyAlarmCorrection))]
public ActionResult ApplyAlarmCorrection([FromBody] AlarmCorrectionRequest req)
{
if (req == null) return BadRequest();
var correction = new DiagnosticResponse
{
Explanation = req.Explanation ?? "",
Causes = req.Causes ?? new List<string>(),
NextSteps = req.NextSteps ?? new List<string>(),
};
var ok = AlarmReviewService.ApplyCorrection(req.BatchNumber, req.AlarmKey ?? "", correction);
return ok ? Ok(new { message = "Korrektur gespeichert." }) : BadRequest("Batch or alarm not found.");
}
[HttpGet(nameof(ReviewAlarms))]
public ActionResult ReviewAlarms(int batch, string reviewer)
{
var html = AlarmReviewService.GetReviewPage(batch, reviewer);
if (html is null) return NotFound("Batch not found or reviewer not recognised.");
return Content(html, "text/html");
}
[HttpPost(nameof(SubmitAlarmReview))]
public async Task<ActionResult> SubmitAlarmReview(int batch, string? reviewer, [FromBody] List<ReviewFeedback>? feedbacks)
{
// Batch 0 = test mode — run dry-run synthesis and return preview HTML (nothing is saved)
if (batch == 0)
{
var previewHtml = await AlarmReviewService.PreviewSynthesisAsync(feedbacks);
return Ok(new { preview = previewHtml });
}
var ok = AlarmReviewService.SubmitFeedback(batch, reviewer, feedbacks);
return ok ? Ok(new { message = "Feedback saved. Thank you!" })
: BadRequest("Batch not found, reviewer not recognised, or already submitted.");
}
[HttpGet(nameof(GetAlarmReviewStatus))]
public ActionResult GetAlarmReviewStatus()
{
return Ok(AlarmReviewService.GetStatus());
}
[HttpGet(nameof(DownloadCheckedKnowledgeBase))]
public ActionResult DownloadCheckedKnowledgeBase()
{
var content = AlarmReviewService.GetCheckedFileContent();
if (content is null) return NotFound("AlarmKnowledgeBaseChecked.cs has not been generated yet.");
return File(System.Text.Encoding.UTF8.GetBytes(content),
"text/plain",
"AlarmKnowledgeBaseChecked.cs");
}
}

View File

@ -0,0 +1,28 @@
namespace InnovEnergy.App.Backend.DataTypes;
/// <summary>
/// Pre-computed behavioral facts derived from hourly data.
/// All heavy analysis is done in C# — the AI only gets these clean conclusions.
/// </summary>
public class BehavioralPattern
{
// Peak hours
public int PeakLoadHour { get; set; } // 0-23, hour of day with highest avg load
public int PeakSolarHour { get; set; } // 0-23, hour with highest avg PV output
public int PeakSolarEndHour { get; set; } // last hour of meaningful solar window
public int HighestGridImportHour { get; set; } // 0-23, hour with most avg grid import
// kWh figures
public double AvgPeakLoadKwh { get; set; } // avg load at peak hour (per day)
public double AvgPeakSolarKwh { get; set; } // avg PV at peak solar hour (per day)
public double AvoidableGridKwh { get; set; } // grid import during hours solar was active
public double AvgGridImportAtPeakHour { get; set; } // avg grid import at worst hour
// Weekday vs weekend
public double WeekdayAvgDailyLoad { get; set; } // avg kWh/day Mon-Fri
public double WeekendAvgDailyLoad { get; set; } // avg kWh/day Sat-Sun
// Battery
public int AvgBatteryDepletedHour { get; set; } // avg hour when SoC first drops below 20%
public bool BatteryDepletesOvernight { get; set; } // true if battery regularly hits low SoC at night
}

View File

@ -0,0 +1,12 @@
namespace InnovEnergy.App.Backend.DataTypes;
public class DailyEnergyData
{
public string Date { get; set; } = ""; // "2026-02-10"
public double PvProduction { get; set; } // kWh
public double LoadConsumption { get; set; } // kWh
public double GridImport { get; set; } // kWh
public double GridExport { get; set; } // kWh
public double BatteryCharged { get; set; } // kWh
public double BatteryDischarged { get; set; } // kWh
}

View File

@ -0,0 +1,19 @@
namespace InnovEnergy.App.Backend.DataTypes;
public class HourlyEnergyData
{
public DateTime DateTime { get; set; } // e.g. 2026-02-14 08:00:00
public int Hour { get; set; } // 0-23
public string DayOfWeek { get; set; } = ""; // "Monday" etc.
public bool IsWeekend { get; set; }
// Energy for this hour (kWh) — derived from diff of consecutive "Today" cumulative snapshots
public double PvKwh { get; set; }
public double LoadKwh { get; set; }
public double GridImportKwh { get; set; }
public double BatteryChargedKwh { get; set; }
public double BatteryDischargedKwh { get; set; }
// Instantaneous state at snapshot time
public double BattSoC { get; set; } // % (0-100)
}

View File

@ -15,7 +15,7 @@ public static class SessionMethods
return user is not null
&& folder is not null
&& user.UserType!=0
&& user.UserType==2
&& user.HasAccessTo(folder.Parent())
&& Db.Create(folder) // TODO: these two in a transaction
&& Db.Create(new FolderAccess { UserId = user.Id, FolderId = folder.Id });
@ -44,7 +44,10 @@ public static class SessionMethods
return user is not null
&& folder is not null
&& user.UserType !=0
&& parent is not null
&& folderId != parentId // can't move into itself
&& !IsFolderAncestorOf(folderId, parentId) // can't move into a descendant
&& user.UserType==2
&& user.HasAccessTo(folder)
&& user.HasAccessTo(parent)
&& folder
@ -52,6 +55,19 @@ public static class SessionMethods
.Apply(Db.Update);
}
// Walks up the folder tree from candidateDescendantId to check if ancestorId is an ancestor.
// Prevents circular references when moving a folder into one of its own descendants.
private static Boolean IsFolderAncestorOf(Int64 ancestorId, Int64 candidateDescendantId)
{
var current = Db.GetFolderById(candidateDescendantId);
while (current is not null && current.ParentId != 0)
{
if (current.ParentId == ancestorId) return true;
current = Db.GetFolderById(current.ParentId);
}
return false;
}
public static Boolean MoveInstallation(this Session? session, Int64 installationId, Int64 parentId)
{
var user = session?.User;
@ -61,7 +77,7 @@ public static class SessionMethods
if(installation == null || installation.ParentId == parentId) return false;
return user is not null
&& user.UserType !=0
&& user.UserType==2
&& user.HasAccessTo(installation)
&& user.HasAccessTo(parent)
&& installation
@ -144,7 +160,7 @@ public static class SessionMethods
var installation = Db.GetInstallationById(action.InstallationId);
installation.TestingMode = action.TestingMode;
installation.Apply(Db.Update);
WebsocketManager.InformWebsocketsForInstallation(action.InstallationId);
await WebsocketManager.InformWebsocketsForInstallation(action.InstallationId);
// Save the configuration change to the database
Db.HandleAction(action);
@ -163,7 +179,7 @@ public static class SessionMethods
{
installation.TestingMode = action.TestingMode;
installation.Apply(Db.Update);
WebsocketManager.InformWebsocketsForInstallation(action.InstallationId);
await WebsocketManager.InformWebsocketsForInstallation(action.InstallationId);
}
Db.UpdateAction(action);
@ -183,7 +199,7 @@ public static class SessionMethods
var installation = Db.GetInstallationById(action.InstallationId);
installation.TestingMode = false;
installation.Apply(Db.Update);
WebsocketManager.InformWebsocketsForInstallation(action.InstallationId);
await WebsocketManager.InformWebsocketsForInstallation(action.InstallationId);
}
Db.Delete(action);
@ -360,7 +376,6 @@ public static class SessionMethods
&& sessionUser.HasAccessTo(originalUser)
&& editedUser
.WithParentOf(originalUser) // prevent moving
.WithNameOf(originalUser)
.WithPasswordOf(originalUser)
.Apply(Db.Update);
}
@ -458,13 +473,25 @@ public static class SessionMethods
{
var sessionUser = session?.User;
return sessionUser is not null
&& installation is not null
&& user is not null
&& user.IsDescendantOf(sessionUser)
&& sessionUser.HasAccessTo(installation)
&& user.HasAccessTo(installation)
&& Db.InstallationAccess.Delete(a => a.UserId == user.Id && a.InstallationId == installation.Id) > 0;
if (sessionUser is null || installation is null || user is null)
return false;
if (!(user.IsDescendantOf(sessionUser) || sessionUser.UserType == 2))
return false;
if (!sessionUser.HasAccessTo(installation) || !user.HasAccessTo(installation))
return false;
// Try direct InstallationAccess record first
if (Db.InstallationAccess.Delete(a => a.UserId == user.Id && a.InstallationId == installation.Id) > 0)
return true;
// No direct record — access is inherited via a folder; revoke that folder access
var accessFolder = installation.Ancestors()
.FirstOrDefault(f => user.HasDirectAccessTo(f));
return accessFolder is not null
&& Db.FolderAccess.Delete(a => a.UserId == user.Id && a.FolderId == accessFolder.Id) > 0;
}
public static Boolean RevokeUserAccessTo(this Session? session, User? user, Folder? folder)
@ -474,7 +501,7 @@ public static class SessionMethods
return sessionUser is not null
&& folder is not null
&& user is not null
&& user.IsDescendantOf(sessionUser)
&& (user.IsDescendantOf(sessionUser) || sessionUser.UserType == 2)
&& sessionUser.HasAccessTo(folder)
&& user.HasAccessTo(folder)
&& Db.FolderAccess.Delete(a => a.UserId == user.Id && a.FolderId == folder.Id) > 0;

View File

@ -237,26 +237,63 @@ public static class UserMethods
public static Task SendPasswordResetEmail(this User user, String token)
{
const String subject = "Reset the password of your Inesco Energy Account";
const String resetLink = "https://monitor.inesco.energy/api/ResetPassword"; // TODO: move to settings file
var encodedToken = HttpUtility.UrlEncode(token);
var body = $"Dear {user.Name}\n" +
$"To reset your password " +
$"please open this link:{resetLink}?token={encodedToken}";
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
"Passwort Ihres Inesco Energy Kontos zurücksetzen",
$"Sehr geehrte/r {user.Name}\n" +
$"Um Ihr Passwort zurückzusetzen, öffnen Sie bitte diesen Link: {resetLink}?token={encodedToken}"
),
"fr" => (
"Réinitialisation du mot de passe de votre compte Inesco Energy",
$"Cher/Chère {user.Name}\n" +
$"Pour réinitialiser votre mot de passe, veuillez ouvrir ce lien : {resetLink}?token={encodedToken}"
),
"it" => (
"Reimposta la password del tuo account Inesco Energy",
$"Gentile {user.Name}\n" +
$"Per reimpostare la password, apra questo link: {resetLink}?token={encodedToken}"
),
_ => (
"Reset the password of your Inesco Energy Account",
$"Dear {user.Name}\n" +
$"To reset your password please open this link: {resetLink}?token={encodedToken}"
)
};
return user.SendEmail(subject, body);
}
public static Task SendNewUserWelcomeMessage(this User user)
{
const String subject = "Your new Inesco Energy Account";
var resetLink = $"https://monitor.inesco.energy/?username={user.Email}"; // TODO: move to settings file
var body = $"Dear {user.Name}\n" +
$"To set your password and log in to your " +
$"Inesco Energy Account open this link:{resetLink}";
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
"Ihr neues Inesco Energy Konto",
$"Sehr geehrte/r {user.Name}\n" +
$"Um Ihr Passwort festzulegen und sich bei Ihrem Inesco Energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}"
),
"fr" => (
"Votre nouveau compte Inesco Energy",
$"Cher/Chère {user.Name}\n" +
$"Pour définir votre mot de passe et vous connecter à votre compte Inesco Energy, veuillez ouvrir ce lien : {resetLink}"
),
"it" => (
"Il tuo nuovo account Inesco Energy",
$"Gentile {user.Name}\n" +
$"Per impostare la password e accedere al suo account Inesco Energy, apra questo link: {resetLink}"
),
_ => (
"Your new Inesco Energy Account",
$"Dear {user.Name}\n" +
$"To set your password and log in to your Inesco Energy Account open this link: {resetLink}"
)
};
return user.SendEmail(subject, body);
}

View File

@ -10,6 +10,7 @@ public class User : TreeNode
public int UserType { get; set; } = 0;
public Boolean MustResetPassword { get; set; } = false;
public String? Password { get; set; } = null!;
public String Language { get; set; } = "en";
[Unique]
public override String Name { get; set; } = null!;

View File

@ -0,0 +1,41 @@
namespace InnovEnergy.App.Backend.DataTypes;
public class WeeklyReportResponse
{
public string InstallationName { get; set; } = "";
public string PeriodStart { get; set; } = ""; // current week start
public string PeriodEnd { get; set; } = ""; // current week end
public WeeklySummary CurrentWeek { get; set; } = new();
public WeeklySummary? PreviousWeek { get; set; }
// Pre-computed savings — single source of truth for UI and AI
public double TotalEnergySaved { get; set; } // kWh = Consumption - GridImport
public double TotalSavingsCHF { get; set; } // CHF = TotalEnergySaved * 0.27
public double DaysEquivalent { get; set; } // TotalEnergySaved / avg daily consumption
// Key ratios (current week)
public double SelfSufficiencyPercent { get; set; }
public double SelfConsumptionPercent { get; set; }
public double BatteryEfficiencyPercent { get; set; }
public double GridDependencyPercent { get; set; }
// Week-over-week change percentages
public double PvChangePercent { get; set; }
public double ConsumptionChangePercent { get; set; }
public double GridImportChangePercent { get; set; }
public List<DailyEnergyData> DailyData { get; set; } = new();
public BehavioralPattern? Behavior { get; set; }
public string AiInsight { get; set; } = "";
}
public class WeeklySummary
{
public double TotalPvProduction { get; set; }
public double TotalConsumption { get; set; }
public double TotalGridImport { get; set; }
public double TotalGridExport { get; set; }
public double TotalBatteryCharged { get; set; }
public double TotalBatteryDischarged { get; set; }
}

View File

@ -53,6 +53,12 @@ public static partial class Db
Connection.CreateTable<UserAction>();
});
// One-time migration: normalize legacy long-form language values to ISO codes
Connection.Execute("UPDATE User SET Language = 'en' WHERE Language IS NULL OR Language = '' OR Language = 'english'");
Connection.Execute("UPDATE User SET Language = 'de' WHERE Language = 'german'");
Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'");
Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'");
//UpdateKeys();
CleanupSessions().SupressAwaitWarning();
DeleteSnapshots().SupressAwaitWarning();

View File

@ -40,9 +40,8 @@ public static partial class Db
var originalUser = GetUserById(user.Id);
if (originalUser is null) return false;
// these columns must not be modified!
// ParentId must not be modified via this method
user.ParentId = originalUser.ParentId;
user.Name = originalUser.Name;
return Update(obj: user);
}

View File

@ -1,8 +1,8 @@
{
"SmtpServerUrl" : "smtp.gmail.com",
"SmtpUsername" : "angelis@inesco.energy",
"SmtpPassword" : "huvu pkqd kakz hqtm ",
"SmtpServerUrl" : "mail.agenturserver.de",
"SmtpUsername" : "no-reply@inesco.ch",
"SmtpPassword" : "1ci4vi%+bfccIp",
"SmtpPort" : 587,
"SenderName" : "Inesco Energy",
"SenderAddress" : "noreply@inesco.energy"
"SenderAddress" : "no-reply@inesco.ch"
}

View File

@ -2,6 +2,7 @@ using System.Diagnostics;
using Flurl.Http;
using Hellang.Middleware.ProblemDetails;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.Services;
using InnovEnergy.App.Backend.Websockets;
using InnovEnergy.App.Backend.DeleteOldData;
using Microsoft.AspNetCore.HttpOverrides;
@ -24,6 +25,9 @@ public static class Program
Watchdog.NotifyReady();
Db.Init();
LoadEnvFile();
DiagnosticService.Initialize();
AlarmReviewService.StartDailyScheduler();
var builder = WebApplication.CreateBuilder(args);
RabbitMqManager.InitializeEnvironment();
@ -87,6 +91,33 @@ public static class Program
app.Run();
}
private static void LoadEnvFile()
{
var envPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ".env");
if (!File.Exists(envPath))
envPath = ".env"; // fallback for dev
if (!File.Exists(envPath))
return;
foreach (var line in File.ReadAllLines(envPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0 || trimmed.StartsWith('#'))
continue;
var idx = trimmed.IndexOf('=');
if (idx <= 0)
continue;
var key = trimmed[..idx].Trim();
var value = trimmed[(idx + 1)..].Trim();
Environment.SetEnvironmentVariable(key, value);
}
}
private static OpenApiInfo OpenApiInfo { get; } = new OpenApiInfo
{
Title = "Inesco Backend API",

View File

@ -0,0 +1,231 @@
{
"AbnormalGridVoltage": "Unnormale Netzspannung",
"AbnormalGridFrequency": "Unnormale Netzfrequenz",
"InvertedSequenceOfGridVoltage": "Falsche Phasenreihenfolge",
"GridVoltagePhaseLoss": "Phasenausfall im Netz",
"AbnormalGridCurrent": "Unnormaler Netzstrom",
"AbnormalOutputVoltage": "Ungewöhnliche Ausgangsspannung",
"AbnormalOutputFrequency": "Ungewöhnliche Ausgangsfrequenz",
"AbnormalNullLine": "Fehlerhafter Nullleiter",
"AbnormalOffGridOutputVoltage": "Ungewöhnliche Backup-Spannung",
"ExcessivelyHighAmbientTemperature": "Zu hohe Umgebungstemperatur",
"ExcessiveRadiatorTemperature": "Überhitzter Kühlkörper",
"PcbOvertemperature": "Überhitzte Leiterplatte",
"DcConverterOvertemperature": "Überhitzter DC-Wandler",
"InverterOvertemperatureAlarm": "Warnung: Überhitzung",
"InverterOvertemperature": "Wechselrichter überhitzt",
"DcConverterOvertemperatureAlarm": "Übertemperaturalarm DC-Wandler",
"InsulationFault": "Isolationsfehler",
"LeakageProtectionFault": "Leckschutzfehler",
"AbnormalLeakageSelfCheck": "Anomaler Leckstrom-Selbsttest",
"PoorGrounding": "Schlechte Erdung",
"FanFault": "Lüfterfehler",
"AuxiliaryPowerFault": "Hilfsstromversorgung Fehler",
"ModelCapacityFault": "Modellkapazitätsfehler",
"AbnormalLightningArrester": "Überspannungsschutz Fehler",
"IslandProtection": "Inselbetrieb Schutz",
"Battery1NotConnected": "Batterie 1 nicht verbunden",
"Battery1Overvoltage": "Batterie 1 Überspannung",
"Battery1Undervoltage": "Batterie 1 Unterspannung",
"Battery1DischargeEnd": "Batterie 1 Entladung beendet",
"Battery1Inverted": "Batterie 1 Polarität vertauscht",
"Battery1OverloadTimeout": "Batterie 1 Überlastung",
"Battery1SoftStartFailure": "Batterie 1 Startfehler",
"Battery1PowerTubeFault": "Batterie 1 Leistungsteil defekt",
"Battery1InsufficientPower": "Batterie 1 Leistung unzureichend",
"Battery1BackupProhibited": "Batterie 1 Backup gesperrt",
"Battery2NotConnected": "Batterie 2 nicht verbunden",
"Battery2Overvoltage": "Batterie 2 Überspannung",
"Battery2Undervoltage": "Batterie 2 Unterspannung",
"Battery2DischargeEnd": "Batterie 2 Entladung beendet",
"Battery2Inverted": "Batterie 2 falsch angeschlossen",
"Battery2OverloadTimeout": "Batterie 2 Überlastung",
"Battery2SoftStartFailure": "Batterie 2 Startfehler",
"Battery2PowerTubeFault": "Batterie 2 Leistungsteil defekt",
"Battery2InsufficientPower": "Batterie 2 Leistung unzureichend",
"Battery2BackupProhibited": "Batterie 2 Backup gesperrt",
"LithiumBattery1ChargeForbidden": "Lithium-Batterie 1 Ladeverbot",
"LithiumBattery1DischargeForbidden": "Lithium-Batterie 1 Entladeverbot",
"LithiumBattery2ChargeForbidden": "Lithium-Batterie 2 Ladeverbot",
"LithiumBattery2DischargeForbidden": "Lithium-Batterie 2 Entladeverbot",
"LithiumBattery1Full": "Lithium-Batterie 1 voll",
"LithiumBattery1DischargeEnd": "Lithium-Batterie 1 entladen",
"LithiumBattery2Full": "Lithium-Batterie 2 voll",
"LithiumBattery2DischargeEnd": "Lithium-Batterie 2 entladen",
"LeadBatteryTemperatureAbnormality": "Batterietemperatur abnormal",
"BatteryAccessMethodError": "Batteriezugriffsfehler",
"Pv1NotAccessed": "PV1 nicht erreichbar",
"Pv1Overvoltage": "PV1 Überspannung",
"AbnormalPv1CurrentSharing": "Ungleichmäßiger PV1-Strom",
"Pv1PowerTubeFault": "PV1 Leistungstubus defekt",
"Pv1SoftStartFailure": "PV1 Soft-Start fehlgeschlagen",
"Pv1OverloadTimeout": "PV1-Überlastung",
"Pv1InsufficientPower": "PV1-Schwacher Strom",
"Photovoltaic1Overcurrent": "PV1-Überstrom",
"Pv2NotAccessed": "PV2-Nicht erkannt",
"Pv2Overvoltage": "PV2-Überspannung",
"AbnormalPv2CurrentSharing": "Ungewöhnliche Stromverteilung PV2",
"Pv2PowerTubeFault": "PV2-Leistungsrohrfehler",
"Pv2SoftStartFailure": "PV2-Softstart fehlgeschlagen",
"Pv2OverloadTimeout": "PV2-Überlastung Timeout",
"Pv2InsufficientPower": "Unzureichende Leistung PV2",
"Pv3NotConnected": "PV3 nicht verbunden",
"Pv3Overvoltage": "PV3 Überspannung",
"Pv3AverageCurrentAnomaly": "PV3 Stromanomalie",
"Pv3PowerTubeFailure": "PV3 Leistungselektronik defekt",
"Pv3SoftStartFailure": "PV3 Startfehler",
"Pv3OverloadTimeout": "PV3-Überlastung",
"Pv3ReverseConnection": "PV3-Falschpolung",
"Pv4NotConnected": "PV4 Nicht Verbunden",
"Pv4Overvoltage": "PV4 Überspannung",
"Pv4AverageCurrentAnomaly": "PV4 Stromanomalie",
"Pv4PowerTubeFailure": "PV4-Leistungsrohr defekt",
"Pv4SoftStartFailure": "PV4-Softstart fehlgeschlagen",
"Pv4OverloadTimeout": "PV4-Überlastung",
"Pv4ReverseConnection": "PV4 falsch angeschlossen",
"InsufficientPhotovoltaicPower": "Zu wenig Solarstrom",
"DcBusOvervoltage": "DC-Bus Überspannung",
"DcBusUndervoltage": "DC-Bus Unterspannung",
"DcBusVoltageUnbalance": "DC-Bus Spannungsungleichgewicht",
"BusSlowOvervoltage": "Langsame DC-Bus Überspannung",
"HardwareBusOvervoltage": "Hardware DC-Bus Überspannung",
"BusSoftStartFailure": "Fehler beim sanften Start",
"InverterPowerTubeFault": "Wechselrichter-Leistungshalbleiter defekt",
"HardwareOvercurrent": "Hardware-Überstrom",
"DcConverterOvervoltage": "DC-Wandler Überspannung",
"DcConverterHardwareOvervoltage": "DC-Wandler Hardware-Überspannung",
"DcConverterOvercurrent": "DC-Wandler Überstrom",
"DcConverterHardwareOvercurrent": "DC-Wandler Hardware-Überstrom",
"DcConverterResonatorOvercurrent": "DC-Wandler Resonanz-Überstrom",
"SystemOutputOverload": "Systemausgang überlastet",
"InverterOverload": "Wechselrichter überlastet",
"InverterOverloadTimeout": "Wechselrichter-Überlastung",
"LoadPowerOverload": "Überlastung der Lastleistung",
"BalancedCircuitOverloadTimeout": "Phasenausgleich-Überlastung",
"InverterSoftStartFailure": "Wechselrichter-Softstart-Fehler",
"Dsp1ParameterSettingFault": "DSP-Parameter-Fehler",
"Dsp2ParameterSettingFault": "DSP2 Parameterfehler",
"DspVersionCompatibilityFault": "DSP-Versionen nicht kompatibel",
"CpldVersionCompatibilityFault": "CPLD-Version nicht kompatibel",
"CpldCommunicationFault": "CPLD-Kommunikationsfehler",
"DspCommunicationFault": "DSP-Kommunikationsfehler",
"OutputVoltageDcOverlimit": "DC-Spannung zu hoch",
"OutputCurrentDcOverlimit": "DC-Strom zu hoch",
"RelaySelfCheckFails": "Relais-Selbsttest fehlgeschlagen",
"InverterRelayOpen": "Wechselrichter-Relais offen",
"InverterRelayShortCircuit": "Wechselrichter-Relais Kurzschluss",
"OpenCircuitOfPowerGridRelay": "Netzrelais offen",
"ShortCircuitOfPowerGridRelay": "Netzrelais kurzgeschlossen",
"GeneratorRelayOpenCircuit": "Generatorrelais offen",
"GeneratorRelayShortCircuit": "Generatorrelais kurzgeschlossen",
"AbnormalInverter": "Wechselrichter abnormal",
"ParallelCommunicationAlarm": "Parallelkommunikationsalarm",
"ParallelModuleMissing": "Parallelmodul fehlt",
"DuplicateMachineNumbersForParallelModules": "Doppelte Gerätenummern",
"ParameterConflictInParallelModule": "Parameterkonflikt im Parallelmodul",
"SystemDerating": "Systemleistung reduziert",
"PvAccessMethodErrorAlarm": "PV-Zugriffsfehler",
"ReservedAlarms4": "Reservierter Alarm 4",
"ReservedAlarms5": "Reservierter Alarm 5",
"ReverseMeterConnection": "Zähler falsch angeschlossen",
"InverterSealPulse": "Wechselrichter-Leistungsbegrenzung",
"AbnormalDieselGeneratorVoltage": "Ungewöhnliche Dieselgenerator-Spannung",
"AbnormalDieselGeneratorFrequency": "Ungewöhnliche Dieselgenerator-Frequenz",
"DieselGeneratorVoltageReverseSequence": "Falsche Phasenfolge des Generators",
"DieselGeneratorVoltageOutOfPhase": "Generator nicht synchronisiert",
"GeneratorOverload": "Generator überlastet",
"StringFault": "PV-String-Fehler",
"PvStringPidQuickConnectAbnormal": "PV-String-Anschluss defekt",
"DcSpdFunctionAbnormal": "DC-Überspannungsschutz defekt",
"PvShortCircuited": "PV-String kurzgeschlossen",
"PvBoostDriverAbnormal": "PV-Boost-Treiber defekt",
"AcSpdFunctionAbnormal": "AC-Überspannungsschutz defekt",
"DcFuseBlown": "DC-Sicherung durchgebrannt",
"DcInputVoltageTooHigh": "DC-Eingangsspannung zu hoch",
"PvReversed": "PV-Polarität vertauscht",
"PidFunctionAbnormal": "PID-Schutzfunktion gestört",
"PvStringDisconnected": "PV-String getrennt",
"PvStringCurrentUnbalanced": "PV-String Strom unausgeglichen",
"NoUtilityGrid": "Kein Stromnetz",
"GridVoltageOutOfRange": "Netzspannung außerhalb des Bereichs",
"GridFrequencyOutOfRange": "Netzfrequenz außerhalb des Bereichs",
"Overload": "Überlastung",
"MeterDisconnected": "Stromzähler getrennt",
"MeterReverselyConnected": "Zähler falsch angeschlossen",
"LinePeVoltageAbnormal": "Abnormale PE-Spannung",
"PhaseSequenceError": "Phasenfolgefehler",
"FanFailure": "Lüfterausfall",
"MeterAbnormal": "Störungsanzeige Zähler",
"OptimizerCommunicationAbnormal": "Kommunikationsstörung Optimierer",
"OverTemperature": "Überhitzung",
"OverTemperatureAlarm": "Überhitzungswarnung",
"NtcTemperatureSensorBroken": "Temperatursensor defekt",
"SyncSignalAbnormal": "Synchronisationsfehler",
"GridStartupConditionsNotMet": "Netzstartbedingungen nicht erfüllt",
"BatteryCommunicationFailure": "Batteriekommunikation fehlgeschlagen",
"BatteryDisconnected": "Batterie getrennt",
"BatteryVoltageTooHigh": "Batteriespannung zu hoch",
"BatteryVoltageTooLow": "Batteriespannung zu niedrig",
"BatteryReverseConnected": "Batterie falsch angeschlossen",
"LeadAcidTempSensorDisconnected": "Temperatursensor nicht angeschlossen",
"BatteryTemperatureOutOfRange": "Batterietemperatur außerhalb des Bereichs",
"BmsFault": "BMS-Fehler",
"LithiumBatteryOverload": "Batterie-Überlastung",
"BmsCommunicationAbnormal": "BMS-Kommunikationsfehler",
"BatterySpdAbnormal": "Batterie-Überspannungsschutz",
"OutputDcComponentBiasAbnormal": "DC-Versatz im Ausgang",
"DcComponentOverHighOutputVoltage": "DC-Komponente zu hohe Ausgangsspannung",
"OffGridOutputVoltageTooLow": "Netzunabhängige Ausgangsspannung zu niedrig",
"OffGridOutputVoltageTooHigh": "Netzunabhängige Ausgangsspannung zu hoch",
"OffGridOutputOverCurrent": "Netzunabhängiger Ausgangsüberstrom",
"OffGridOutputOverload": "Netzunabhängiger Ausgang überlastet",
"BalancedCircuitAbnormal": "Phasenausgleich gestört",
"ExportLimitationFailSafe": "Exportbegrenzung Notaus",
"DcBiasAbnormal": "DC-Vorspannung abnormal",
"HighDcComponentOutputCurrent": "Hohe DC-Komponente im Ausgangsstrom",
"BusVoltageSamplingAbnormal": "Spannungsmessung defekt",
"RelayFault": "Relaisfehler",
"BusVoltageAbnormal": "Gleichspannung abnormal",
"InternalCommunicationFailure": "Interne Kommunikation ausgefallen",
"TemperatureSensorDisconnected": "Temperatursensor getrennt",
"IgbtDriveFault": "IGBT-Ansteuerungsfehler",
"EepromError": "EEPROM-Fehler",
"AuxiliaryPowerAbnormal": "Hilfsstromversorgung abnormal",
"DcAcOvercurrentProtection": "Überstromschutz aktiviert",
"CommunicationProtocolMismatch": "Kommunikationsprotokoll-Fehler",
"DspComFirmwareMismatch": "Firmware-Inkompatibilität DSP/COM",
"DspSoftwareHardwareMismatch": "DSP-Software-Hardware-Inkompatibilität",
"CpldAbnormal": "CPLD-Fehler",
"RedundancySamplingInconsistent": "Inkonsistente redundante Messungen",
"PwmPassThroughSignalFailure": "PWM-Signalweg ausgefallen",
"AfciSelfTestFailure": "AFCI-Selbsttest fehlgeschlagen",
"PvCurrentSamplingAbnormal": "PV-Strommessung abnormal",
"AcCurrentSamplingAbnormal": "AC-Strommessung abnormal",
"BusSoftbootFailure": "DC-Bus-Vorstart fehlgeschlagen",
"EpoFault": "EPO-Fehler (Notaus)",
"MonitoringChipBootVerificationFailed": "Überwachungs-Chip Startfehler",
"BmsCommunicationFailure": "BMS-Kommunikationsfehler",
"BmsChargeDischargeFailure": "BMS-Lade-/Entladefehler",
"BatteryVoltageLow": "Batteriespannung zu niedrig",
"BatteryVoltageHigh": "Batteriespannung zu hoch",
"BatteryTemperatureAbnormal": "Batterietemperatur ungewöhnlich",
"BatteryReversed": "Batterie verkehrt herum",
"BatteryOpenCircuit": "Batteriekreis offen",
"BatteryOverloadProtection": "Batterieüberlastungsschutz",
"Bus2VoltageAbnormal": "Bus2-Spannung ungewöhnlich",
"BatteryChargeOcp": "Batterieladung Überstrom",
"BatteryDischargeOcp": "Batterieentladung Überstrom",
"BatterySoftStartFailed": "Batterie-Softstart fehlgeschlagen",
"EpsOutputShortCircuited": "EPS-Ausgang kurzgeschlossen",
"OffGridBusVoltageLow": "Netzunabhängige Busspannung zu niedrig",
"OffGridTerminalVoltageAbnormal": "Abnormale Spannung am Netzausgang",
"SoftStartFailed": "Sanfter Start fehlgeschlagen",
"OffGridOutputVoltageAbnormal": "Abnormale Ausgangsspannung im Netzmodus",
"BalancedCircuitSelfTestFailed": "Ausgleichsschaltungstest fehlgeschlagen",
"HighDcComponentOutputVoltage": "Hohe Gleichspannungskomponente im Ausgang",
"OffGridParallelSignalAbnormal": "Parallelsignalstörung",
"AFCIFault": "Lichtbogenfehler",
"GFCIHigh": "Erhöhter Fehlerstrom",
"PVVoltageHigh": "PV-Spannung zu hoch",
"OffGridBusVoltageTooLow": "Off-Grid-Busspannung zu niedrig"
}

View File

@ -0,0 +1,231 @@
{
"alarm_AbnormalGridVoltage": "Tension réseau anormale",
"alarm_AbnormalGridFrequency": "Fréquence réseau anormale",
"alarm_InvertedSequenceOfGridVoltage": "Séquence de tension inversée",
"alarm_GridVoltagePhaseLoss": "Perte de phase réseau",
"alarm_AbnormalGridCurrent": "Courant réseau anormal",
"alarm_AbnormalOutputVoltage": "Tension de sortie anormale",
"alarm_AbnormalOutputFrequency": "Fréquence de sortie anormale",
"alarm_AbnormalNullLine": "Ligne neutre anormale",
"alarm_AbnormalOffGridOutputVoltage": "Tension de sortie hors réseau anormale",
"alarm_ExcessivelyHighAmbientTemperature": "Température ambiante trop élevée",
"alarm_ExcessiveRadiatorTemperature": "Température excessive du radiateur",
"alarm_PcbOvertemperature": "Température excessive PCB",
"alarm_DcConverterOvertemperature": "Température excessive convertisseur DC",
"alarm_InverterOvertemperatureAlarm": "Alarme température onduleur",
"alarm_InverterOvertemperature": "Température onduleur excessive",
"alarm_DcConverterOvertemperatureAlarm": "Alarme surchauffe convertisseur DC",
"alarm_InsulationFault": "Défaut d'isolation",
"alarm_LeakageProtectionFault": "Défaut protection fuite",
"alarm_AbnormalLeakageSelfCheck": "Auto-test fuite anormale",
"alarm_PoorGrounding": "Mise à la terre insuffisante",
"alarm_FanFault": "Défaut du ventilateur",
"alarm_AuxiliaryPowerFault": "Défaut d'alimentation auxiliaire",
"alarm_ModelCapacityFault": "Défaut de configuration",
"alarm_AbnormalLightningArrester": "Paratonnerre défectueux",
"alarm_IslandProtection": "Protection d'îlotage",
"alarm_Battery1NotConnected": "Batterie 1 non connectée",
"alarm_Battery1Overvoltage": "Tension batterie 1 trop élevée",
"alarm_Battery1Undervoltage": "Tension batterie 1 trop basse",
"alarm_Battery1DischargeEnd": "Fin de décharge batterie 1",
"alarm_Battery1Inverted": "Polarité batterie 1 inversée",
"alarm_Battery1OverloadTimeout": "Dépassement de charge Batterie 1",
"alarm_Battery1SoftStartFailure": "Échec démarrage Batterie 1",
"alarm_Battery1PowerTubeFault": "Défaut électronique Batterie 1",
"alarm_Battery1InsufficientPower": "Puissance insuffisante Batterie 1",
"alarm_Battery1BackupProhibited": "Sauvegarde interdite Batterie 1",
"alarm_Battery2NotConnected": "Batterie 2 non connectée",
"alarm_Battery2Overvoltage": "Tension batterie 2 élevée",
"alarm_Battery2Undervoltage": "Tension batterie 2 basse",
"alarm_Battery2DischargeEnd": "Fin décharge batterie 2",
"alarm_Battery2Inverted": "Polarité batterie 2 inversée",
"alarm_Battery2OverloadTimeout": "Dépassement de charge Batterie 2",
"alarm_Battery2SoftStartFailure": "Échec démarrage Batterie 2",
"alarm_Battery2PowerTubeFault": "Défaut électronique Batterie 2",
"alarm_Battery2InsufficientPower": "Puissance insuffisante Batterie 2",
"alarm_Battery2BackupProhibited": "Sauvegarde interdite Batterie 2",
"alarm_LithiumBattery1ChargeForbidden": "Charge batterie lithium 1 interdite",
"alarm_LithiumBattery1DischargeForbidden": "Décharge batterie lithium 1 interdite",
"alarm_LithiumBattery2ChargeForbidden": "Charge batterie lithium 2 interdite",
"alarm_LithiumBattery2DischargeForbidden": "Décharge batterie lithium 2 interdite",
"alarm_LithiumBattery1Full": "Batterie lithium 1 pleine",
"alarm_LithiumBattery1DischargeEnd": "Fin de décharge batterie lithium 1",
"alarm_LithiumBattery2Full": "Batterie lithium 2 pleine",
"alarm_LithiumBattery2DischargeEnd": "Fin de décharge batterie lithium 2",
"alarm_LeadBatteryTemperatureAbnormality": "Température anormale batterie plomb",
"alarm_BatteryAccessMethodError": "Erreur de méthode d'accès batterie",
"alarm_Pv1NotAccessed": "Chaîne PV1 non accessible",
"alarm_Pv1Overvoltage": "Survoltage PV1",
"alarm_AbnormalPv1CurrentSharing": "Partage de courant PV1 anormal",
"alarm_Pv1PowerTubeFault": "Défaut du tube de puissance PV1",
"alarm_Pv1SoftStartFailure": "Échec de démarrage doux PV1",
"alarm_Pv1OverloadTimeout": "Dépassement de charge PV1",
"alarm_Pv1InsufficientPower": "Puissance PV1 insuffisante",
"alarm_Photovoltaic1Overcurrent": "Surintensité PV1",
"alarm_Pv2NotAccessed": "Chaîne PV2 inaccessible",
"alarm_Pv2Overvoltage": "Survoltage PV2",
"alarm_AbnormalPv2CurrentSharing": "Partage de courant anormal PV2",
"alarm_Pv2PowerTubeFault": "Défaillance du tube de puissance PV2",
"alarm_Pv2SoftStartFailure": "Échec de démarrage progressif PV2",
"alarm_Pv2OverloadTimeout": "Dépassement de charge PV2",
"alarm_Pv2InsufficientPower": "Puissance insuffisante PV2",
"alarm_Pv3NotConnected": "PV3 non connecté",
"alarm_Pv3Overvoltage": "Survoltage PV3",
"alarm_Pv3AverageCurrentAnomaly": "Anomalie courant PV3",
"alarm_Pv3PowerTubeFailure": "Défaillance tube PV3",
"alarm_Pv3SoftStartFailure": "Échec démarrage PV3",
"alarm_Pv3OverloadTimeout": "Dépassement de charge PV3",
"alarm_Pv3ReverseConnection": "Connexion inversée PV3",
"alarm_Pv4NotConnected": "Chaîne PV4 non connectée",
"alarm_Pv4Overvoltage": "Survoltage PV4",
"alarm_Pv4AverageCurrentAnomaly": "Anomalie de courant PV4",
"alarm_Pv4PowerTubeFailure": "Défaillance du tube de puissance PV4",
"alarm_Pv4SoftStartFailure": "Échec du démarrage progressif PV4",
"alarm_Pv4OverloadTimeout": "Dépassement de charge PV4",
"alarm_Pv4ReverseConnection": "Connexion inversée PV4",
"alarm_InsufficientPhotovoltaicPower": "Puissance photovoltaïque insuffisante",
"alarm_DcBusOvervoltage": "Tension DC trop élevée",
"alarm_DcBusUndervoltage": "Tension DC trop basse",
"alarm_DcBusVoltageUnbalance": "Déséquilibre tension DC",
"alarm_BusSlowOvervoltage": "Tension DC lente excessive",
"alarm_HardwareBusOvervoltage": "Tension DC critique",
"alarm_BusSoftStartFailure": "Échec démarrage progressif",
"alarm_InverterPowerTubeFault": "Défaut tube de puissance",
"alarm_HardwareOvercurrent": "Surintensité matérielle",
"alarm_DcConverterOvervoltage": "Survoltage convertisseur DC",
"alarm_DcConverterHardwareOvervoltage": "Survoltage matériel convertisseur DC",
"alarm_DcConverterOvercurrent": "Surintensité convertisseur CC",
"alarm_DcConverterHardwareOvercurrent": "Surintensité matérielle convertisseur CC",
"alarm_DcConverterResonatorOvercurrent": "Surintensité résonateur convertisseur CC",
"alarm_SystemOutputOverload": "Surcharge de sortie système",
"alarm_InverterOverload": "Surcharge onduleur",
"alarm_InverterOverloadTimeout": "Dépassement de charge de l'onduleur",
"alarm_LoadPowerOverload": "Surcharge de puissance de charge",
"alarm_BalancedCircuitOverloadTimeout": "Dépassement de charge du circuit équilibré",
"alarm_InverterSoftStartFailure": "Échec de démarrage progressif de l'onduleur",
"alarm_Dsp1ParameterSettingFault": "Défaillance de paramétrage DSP 1",
"alarm_Dsp2ParameterSettingFault": "Paramètre DSP2 incorrect",
"alarm_DspVersionCompatibilityFault": "Incompatibilité version DSP",
"alarm_CpldVersionCompatibilityFault": "Incompatibilité version CPLD",
"alarm_CpldCommunicationFault": "Échec communication CPLD",
"alarm_DspCommunicationFault": "Échec communication DSP",
"alarm_OutputVoltageDcOverlimit": "Tension de sortie DC excessive",
"alarm_OutputCurrentDcOverlimit": "Courant de sortie DC excessif",
"alarm_RelaySelfCheckFails": "Auto-test relais échoué",
"alarm_InverterRelayOpen": "Relais de l'onduleur ouvert",
"alarm_InverterRelayShortCircuit": "Relais de l'onduleur en court-circuit",
"alarm_OpenCircuitOfPowerGridRelay": "Relais du réseau ouvert",
"alarm_ShortCircuitOfPowerGridRelay": "Court-circuit du relais réseau",
"alarm_GeneratorRelayOpenCircuit": "Relais du générateur ouvert",
"alarm_GeneratorRelayShortCircuit": "Court-circuit du relais générateur",
"alarm_AbnormalInverter": "Onduleur anormal",
"alarm_ParallelCommunicationAlarm": "Alarme de communication parallèle",
"alarm_ParallelModuleMissing": "Module parallèle manquant",
"alarm_DuplicateMachineNumbersForParallelModules": "Numéros de machine en double",
"alarm_ParameterConflictInParallelModule": "Conflit de paramètres parallèle",
"alarm_SystemDerating": "Réduction de puissance du système",
"alarm_PvAccessMethodErrorAlarm": "Erreur méthode d'accès PV",
"alarm_ReservedAlarms4": "Alarme réservée 4",
"alarm_ReservedAlarms5": "Alarme réservée 5",
"alarm_ReverseMeterConnection": "Connexion du compteur inversée",
"alarm_InverterSealPulse": "Impulsion de scellement de l'onduleur",
"alarm_AbnormalDieselGeneratorVoltage": "Tension anormale du générateur diesel",
"alarm_AbnormalDieselGeneratorFrequency": "Fréquence anormale du générateur diesel",
"alarm_DieselGeneratorVoltageReverseSequence": "Séquence de phase inversée du générateur",
"alarm_DieselGeneratorVoltageOutOfPhase": "Déphasage du générateur",
"alarm_GeneratorOverload": "Surcharge du générateur",
"alarm_StringFault": "Défaut de chaîne",
"alarm_PvStringPidQuickConnectAbnormal": "Connexion rapide anormale",
"alarm_DcSpdFunctionAbnormal": "Problème de protection DC",
"alarm_PvShortCircuited": "Court-circuit PV",
"alarm_PvBoostDriverAbnormal": "Problème de convertisseur",
"alarm_AcSpdFunctionAbnormal": "Problème de protection contre les surtensions AC",
"alarm_DcFuseBlown": "Fusible DC grillé",
"alarm_DcInputVoltageTooHigh": "Tension DC d'entrée trop élevée",
"alarm_PvReversed": "Polarité PV inversée",
"alarm_PidFunctionAbnormal": "Problème de fonction PID",
"alarm_PvStringDisconnected": "Chaîne PV déconnectée",
"alarm_PvStringCurrentUnbalanced": "Déséquilibre de courant PV",
"alarm_NoUtilityGrid": "Réseau électrique absent",
"alarm_GridVoltageOutOfRange": "Tension réseau hors plage",
"alarm_GridFrequencyOutOfRange": "Fréquence réseau hors plage",
"alarm_Overload": "Surcharge",
"alarm_MeterDisconnected": "Compteur déconnecté",
"alarm_MeterReverselyConnected": "Compteur inversé",
"alarm_LinePeVoltageAbnormal": "Tension anormale",
"alarm_PhaseSequenceError": "Séquence de phase erronée",
"alarm_FanFailure": "Défaillance du ventilateur",
"alarm_MeterAbnormal": "Compteur anormal",
"alarm_OptimizerCommunicationAbnormal": "Communication optimiseur anormale",
"alarm_OverTemperature": "Température excessive",
"alarm_OverTemperatureAlarm": "Alarme température élevée",
"alarm_NtcTemperatureSensorBroken": "Capteur de température défectueux",
"alarm_SyncSignalAbnormal": "Signal de synchronisation anormal",
"alarm_GridStartupConditionsNotMet": "Conditions de démarrage réseau non remplies",
"alarm_BatteryCommunicationFailure": "Échec de communication batterie",
"alarm_BatteryDisconnected": "Batterie déconnectée",
"alarm_BatteryVoltageTooHigh": "Tension batterie trop élevée",
"alarm_BatteryVoltageTooLow": "Tension batterie trop basse",
"alarm_BatteryReverseConnected": "Batterie branchée à l'envers",
"alarm_LeadAcidTempSensorDisconnected": "Capteur température batterie plomb désactivé",
"alarm_BatteryTemperatureOutOfRange": "Température batterie hors plage",
"alarm_BmsFault": "Défaillance BMS",
"alarm_LithiumBatteryOverload": "Surcharge batterie lithium",
"alarm_BmsCommunicationAbnormal": "Communication BMS anormale",
"alarm_BatterySpdAbnormal": "Défaillance SPD batterie",
"alarm_OutputDcComponentBiasAbnormal": "Biais DC de sortie anormal",
"alarm_DcComponentOverHighOutputVoltage": "Tension de sortie trop élevée",
"alarm_OffGridOutputVoltageTooLow": "Tension de sortie hors réseau trop basse",
"alarm_OffGridOutputVoltageTooHigh": "Tension de sortie hors réseau trop élevée",
"alarm_OffGridOutputOverCurrent": "Courant de sortie hors réseau trop élevé",
"alarm_OffGridOutputOverload": "Surcharge sortie hors réseau",
"alarm_BalancedCircuitAbnormal": "Circuit équilibré anormal",
"alarm_ExportLimitationFailSafe": "Sécurité limite d'exportation",
"alarm_DcBiasAbnormal": "Biais DC anormal",
"alarm_HighDcComponentOutputCurrent": "Composante DC élevée courant de sortie",
"alarm_BusVoltageSamplingAbnormal": "Tension d'alimentation anormale",
"alarm_RelayFault": "Défaillance du relais",
"alarm_BusVoltageAbnormal": "Tension d'alimentation anormale",
"alarm_InternalCommunicationFailure": "Échec de communication interne",
"alarm_TemperatureSensorDisconnected": "Capteur de température déconnecté",
"alarm_IgbtDriveFault": "Défaillance de l'IGBT",
"alarm_EepromError": "Erreur EEPROM",
"alarm_AuxiliaryPowerAbnormal": "Alimentation auxiliaire anormale",
"alarm_DcAcOvercurrentProtection": "Protection contre les surintensités",
"alarm_CommunicationProtocolMismatch": "Incompatibilité de protocole",
"alarm_DspComFirmwareMismatch": "Incompatibilité firmware DSP/COM",
"alarm_DspSoftwareHardwareMismatch": "Incompatibilité logiciel DSP/matériel",
"alarm_CpldAbnormal": "CPLD anormal",
"alarm_RedundancySamplingInconsistent": "Échantillonnage redondant incohérent",
"alarm_PwmPassThroughSignalFailure": "Échec signal PWM",
"alarm_AfciSelfTestFailure": "Échec auto-test AFCI",
"alarm_PvCurrentSamplingAbnormal": "Mesure PV anormale",
"alarm_AcCurrentSamplingAbnormal": "Mesure AC anormale",
"alarm_BusSoftbootFailure": "Échec démarrage DC",
"alarm_EpoFault": "Défaillance EPO",
"alarm_MonitoringChipBootVerificationFailed": "Échec vérification démarrage",
"alarm_BmsCommunicationFailure": "Échec communication BMS",
"alarm_BmsChargeDischargeFailure": "Échec charge/décharge BMS",
"alarm_BatteryVoltageLow": "Tension batterie faible",
"alarm_BatteryVoltageHigh": "Tension batterie élevée",
"alarm_BatteryTemperatureAbnormal": "Température anormale de la batterie",
"alarm_BatteryReversed": "Batterie inversée",
"alarm_BatteryOpenCircuit": "Circuit batterie ouvert",
"alarm_BatteryOverloadProtection": "Protection contre la surcharge",
"alarm_Bus2VoltageAbnormal": "Tension anormale Bus2",
"alarm_BatteryChargeOcp": "Surintensité charge batterie",
"alarm_BatteryDischargeOcp": "Surintensité décharge batterie",
"alarm_BatterySoftStartFailed": "Démarrage en douceur échoué",
"alarm_EpsOutputShortCircuited": "Circuit de secours en court-circuit",
"alarm_OffGridBusVoltageLow": "Tension bus hors réseau basse",
"alarm_OffGridTerminalVoltageAbnormal": "Tension anormale terminal hors réseau",
"alarm_SoftStartFailed": "Démarrage progressif échoué",
"alarm_OffGridOutputVoltageAbnormal": "Tension de sortie hors réseau anormale",
"alarm_BalancedCircuitSelfTestFailed": "Autotest circuit équilibré échoué",
"alarm_HighDcComponentOutputVoltage": "Tension de sortie à composante CC élevée",
"alarm_OffGridParallelSignalAbnormal": "Signal parallèle hors réseau anormal",
"alarm_AFCIFault": "Défaillance AFCI",
"alarm_GFCIHigh": "Courant de défaut élevé",
"alarm_PVVoltageHigh": "Tension PV élevée",
"alarm_OffGridBusVoltageTooLow": "Tension du bus hors réseau trop faible"
}

View File

@ -0,0 +1,231 @@
{
"alarm_AbnormalGridVoltage": "Tensione di rete anomala",
"alarm_AbnormalGridFrequency": "Frequenza di rete anomala",
"alarm_InvertedSequenceOfGridVoltage": "Sequenza di fase invertita",
"alarm_GridVoltagePhaseLoss": "Mancanza di fase rete",
"alarm_AbnormalGridCurrent": "Corrente di rete anomala",
"alarm_AbnormalOutputVoltage": "Tensione di uscita anomala",
"alarm_AbnormalOutputFrequency": "Frequenza di uscita anomala",
"alarm_AbnormalNullLine": "Linea neutra anomala",
"alarm_AbnormalOffGridOutputVoltage": "Tensione di uscita in standby anomala",
"alarm_ExcessivelyHighAmbientTemperature": "Temperatura ambientale troppo alta",
"alarm_ExcessiveRadiatorTemperature": "Temperatura radiatore troppo alta",
"alarm_PcbOvertemperature": "Scheda elettronica troppo calda",
"alarm_DcConverterOvertemperature": "Sovratemperatura convertitore DC",
"alarm_InverterOvertemperatureAlarm": "Allarme surriscaldamento inverter",
"alarm_InverterOvertemperature": "Surriscaldamento inverter",
"alarm_DcConverterOvertemperatureAlarm": "Allarme sovratemperatura convertitore DC",
"alarm_InsulationFault": "Guasto isolamento",
"alarm_LeakageProtectionFault": "Guasto protezione dispersione",
"alarm_AbnormalLeakageSelfCheck": "Autocontrollo perdite anomalo",
"alarm_PoorGrounding": "Messa a terra insufficiente",
"alarm_FanFault": "Guasto ventilatore",
"alarm_AuxiliaryPowerFault": "Guasto Alimentazione Ausiliaria",
"alarm_ModelCapacityFault": "Guasto Configurazione Modello",
"alarm_AbnormalLightningArrester": "Parasurtense Anomalo",
"alarm_IslandProtection": "Protezione Isola",
"alarm_Battery1NotConnected": "Batteria 1 Non Connessa",
"alarm_Battery1Overvoltage": "Batteria 1 Sovratensione",
"alarm_Battery1Undervoltage": "Batteria 1 sottotensione",
"alarm_Battery1DischargeEnd": "Fine scarica batteria 1",
"alarm_Battery1Inverted": "Batteria 1 invertita",
"alarm_Battery1OverloadTimeout": "Tempo di sovraccarico batteria 1",
"alarm_Battery1SoftStartFailure": "Avvio morbido batteria 1 fallito",
"alarm_Battery1PowerTubeFault": "Guasto modulo di potenza batteria 1",
"alarm_Battery1InsufficientPower": "Batteria 1 Potenza Insufficiente",
"alarm_Battery1BackupProhibited": "Backup Batteria 1 Bloccato",
"alarm_Battery2NotConnected": "Batteria 2 Non Connessa",
"alarm_Battery2Overvoltage": "Sovratensione Batteria 2",
"alarm_Battery2Undervoltage": "Sottotensione Batteria 2",
"alarm_Battery2DischargeEnd": "Fine Scarica Batteria 2",
"alarm_Battery2Inverted": "Polarità batteria 2 invertita",
"alarm_Battery2OverloadTimeout": "Sovraccarico batteria 2",
"alarm_Battery2SoftStartFailure": "Avvio batteria 2 fallito",
"alarm_Battery2PowerTubeFault": "Guasto modulo potenza batteria 2",
"alarm_Battery2InsufficientPower": "Potenza insufficiente batteria 2",
"alarm_Battery2BackupProhibited": "Backup vietato batteria 2",
"alarm_LithiumBattery1ChargeForbidden": "Carica Batteria Litio 1 Bloccata",
"alarm_LithiumBattery1DischargeForbidden": "Scarica Batteria Litio 1 Bloccata",
"alarm_LithiumBattery2ChargeForbidden": "Carica Batteria Litio 2 Bloccata",
"alarm_LithiumBattery2DischargeForbidden": "Scarica Batteria Litio 2 Bloccata",
"alarm_LithiumBattery1Full": "Batteria Litio 1 Piena",
"alarm_LithiumBattery1DischargeEnd": "Fine Scarica Batteria Litio 1",
"alarm_LithiumBattery2Full": "Batteria Litio 2 Piena",
"alarm_LithiumBattery2DischargeEnd": "Fine Scarica Batteria 2",
"alarm_LeadBatteryTemperatureAbnormality": "Temperatura Batteria Anomala",
"alarm_BatteryAccessMethodError": "Errore Metodo Accesso Batteria",
"alarm_Pv1NotAccessed": "PV1 Non Rilevato",
"alarm_Pv1Overvoltage": "Sovratensione PV1",
"alarm_AbnormalPv1CurrentSharing": "Corrente PV1 anomala",
"alarm_Pv1PowerTubeFault": "Guasto tubo di potenza PV1",
"alarm_Pv1SoftStartFailure": "Avvio morbido PV1 fallito",
"alarm_Pv1OverloadTimeout": "Sovraccarico PV1",
"alarm_Pv1InsufficientPower": "Bassa potenza PV1",
"alarm_Photovoltaic1Overcurrent": "Sovracorrente PV1",
"alarm_Pv2NotAccessed": "PV2 non accessibile",
"alarm_Pv2Overvoltage": "Sovratensione PV2",
"alarm_AbnormalPv2CurrentSharing": "Condivisione corrente PV2 anomala",
"alarm_Pv2PowerTubeFault": "Guasto Tubo di Potenza PV2",
"alarm_Pv2SoftStartFailure": "Avvio Morbido PV2 Fallito",
"alarm_Pv2OverloadTimeout": "Sovraccarico PV2 Scaduto",
"alarm_Pv2InsufficientPower": "Potenza PV2 insufficiente",
"alarm_Pv3NotConnected": "PV3 non connesso",
"alarm_Pv3Overvoltage": "Sovratensione PV3",
"alarm_Pv3AverageCurrentAnomaly": "Anomalia Corrente Media PV3",
"alarm_Pv3PowerTubeFailure": "Guasto Tubo di Potenza PV3",
"alarm_Pv3SoftStartFailure": "Guasto Avvio Morbido PV3",
"alarm_Pv3OverloadTimeout": "Sovraccarico Pv3",
"alarm_Pv3ReverseConnection": "Collegamento Inverso Pv3",
"alarm_Pv4NotConnected": "Pv4 Non Collegato",
"alarm_Pv4Overvoltage": "Sovratensione PV4",
"alarm_Pv4AverageCurrentAnomaly": "Anomalia Corrente Media PV4",
"alarm_Pv4PowerTubeFailure": "Guasto Modulo di Potenza PV4",
"alarm_Pv4SoftStartFailure": "Avvio morbido fallito PV4",
"alarm_Pv4OverloadTimeout": "Sovraccarico prolungato PV4",
"alarm_Pv4ReverseConnection": "Connessione invertita PV4",
"alarm_InsufficientPhotovoltaicPower": "Potenza fotovoltaica insufficiente",
"alarm_DcBusOvervoltage": "Sovratensione bus DC",
"alarm_DcBusUndervoltage": "Sottotensione bus DC",
"alarm_DcBusVoltageUnbalance": "Squilibrio tensione DC",
"alarm_BusSlowOvervoltage": "Sovratensione lenta del bus",
"alarm_HardwareBusOvervoltage": "Sovratensione hardware del bus",
"alarm_BusSoftStartFailure": "Avvio morbido fallito",
"alarm_InverterPowerTubeFault": "Guasto modulo inverter",
"alarm_HardwareOvercurrent": "Sovracorrente hardware",
"alarm_DcConverterOvervoltage": "Sovratensione convertitore DC",
"alarm_DcConverterHardwareOvervoltage": "Protezione sovratensione hardware",
"alarm_DcConverterOvercurrent": "Sovracorrente convertitore DC",
"alarm_DcConverterHardwareOvercurrent": "Sovracorrente hardware convertitore DC",
"alarm_DcConverterResonatorOvercurrent": "Sovracorrente risonatore convertitore DC",
"alarm_SystemOutputOverload": "Sovraccarico uscita sistema",
"alarm_InverterOverload": "Sovraccarico Inverter",
"alarm_InverterOverloadTimeout": "Sovraccarico Inverter Prolungato",
"alarm_LoadPowerOverload": "Carico Elettrico Eccessivo",
"alarm_BalancedCircuitOverloadTimeout": "Sovraccarico circuito bilanciato",
"alarm_InverterSoftStartFailure": "Avvio inverter fallito",
"alarm_Dsp1ParameterSettingFault": "Parametri DSP1 errati",
"alarm_Dsp2ParameterSettingFault": "Errore configurazione parametri DSP 2",
"alarm_DspVersionCompatibilityFault": "Errore compatibilità versione DSP",
"alarm_CpldVersionCompatibilityFault": "Errore compatibilità versione CPLD",
"alarm_CpldCommunicationFault": "Guasto comunicazione CPLD",
"alarm_DspCommunicationFault": "Guasto comunicazione DSP",
"alarm_OutputVoltageDcOverlimit": "Tensione DC in uscita eccessiva",
"alarm_OutputCurrentDcOverlimit": "Corrente DC in uscita superata",
"alarm_RelaySelfCheckFails": "Autotest relè fallito",
"alarm_InverterRelayOpen": "Relè inverter aperto",
"alarm_InverterRelayShortCircuit": "Cortocircuito del relè dell'inverter",
"alarm_OpenCircuitOfPowerGridRelay": "Relè di rete aperto",
"alarm_ShortCircuitOfPowerGridRelay": "Cortocircuito del relè di rete",
"alarm_GeneratorRelayOpenCircuit": "Relè generatore aperto",
"alarm_GeneratorRelayShortCircuit": "Relè generatore corto circuito",
"alarm_AbnormalInverter": "Inverter anomalo",
"alarm_ParallelCommunicationAlarm": "Allarme Comunicazione Parallela",
"alarm_ParallelModuleMissing": "Modulo Parallelo Mancante",
"alarm_DuplicateMachineNumbersForParallelModules": "Numeri Duplicati Moduli Paralleli",
"alarm_ParameterConflictInParallelModule": "Conflitto parametri modulo parallelo",
"alarm_SystemDerating": "Riduzione prestazioni sistema",
"alarm_PvAccessMethodErrorAlarm": "Errore metodo accesso PV",
"alarm_ReservedAlarms4": "Allarme Riservato 4",
"alarm_ReservedAlarms5": "Allarme Riservato 5",
"alarm_ReverseMeterConnection": "Contatore Inverso",
"alarm_InverterSealPulse": "Impulso Sigillo Inverter",
"alarm_AbnormalDieselGeneratorVoltage": "Tensione Generatore Diesel Anomala",
"alarm_AbnormalDieselGeneratorFrequency": "Frequenza Generatore Diesel Anomala",
"alarm_DieselGeneratorVoltageReverseSequence": "Sequenza di fase invertita",
"alarm_DieselGeneratorVoltageOutOfPhase": "Fase del generatore errata",
"alarm_GeneratorOverload": "Sovraccarico del generatore",
"alarm_StringFault": "Guasto alla stringa",
"alarm_PvStringPidQuickConnectAbnormal": "Connessione rapida anomala",
"alarm_DcSpdFunctionAbnormal": "Protezione sovratensione DC anomala",
"alarm_PvShortCircuited": "Cortocircuito PV",
"alarm_PvBoostDriverAbnormal": "Anomalia driver di boost PV",
"alarm_AcSpdFunctionAbnormal": "Anomalia protezione da sovratensioni AC",
"alarm_DcFuseBlown": "Fusibile DC saltato",
"alarm_DcInputVoltageTooHigh": "Tensione DC troppo alta",
"alarm_PvReversed": "Polarità PV invertita",
"alarm_PidFunctionAbnormal": "Funzione PID Anomala",
"alarm_PvStringDisconnected": "Stringa PV Disconnessa",
"alarm_PvStringCurrentUnbalanced": "Corrente Stringa PV Squilibrata",
"alarm_NoUtilityGrid": "Nessuna rete elettrica",
"alarm_GridVoltageOutOfRange": "Tensione di rete fuori limite",
"alarm_GridFrequencyOutOfRange": "Frequenza di rete fuori limite",
"alarm_Overload": "Sovraccarico",
"alarm_MeterDisconnected": "Contatore scollegato",
"alarm_MeterReverselyConnected": "Contatore collegato inversamente",
"alarm_LinePeVoltageAbnormal": "Tensione PE anomala",
"alarm_PhaseSequenceError": "Errore sequenza fase",
"alarm_FanFailure": "Guasto ventola",
"alarm_MeterAbnormal": "Contatore Anomalo",
"alarm_OptimizerCommunicationAbnormal": "Comunicazione Ottimizzatore Anomala",
"alarm_OverTemperature": "Temperatura Eccessiva",
"alarm_OverTemperatureAlarm": "Allarme Temperatura Elevata",
"alarm_NtcTemperatureSensorBroken": "Sensore Temperatura NTC Guasto",
"alarm_SyncSignalAbnormal": "Segnale di Sincronizzazione Anomalo",
"alarm_GridStartupConditionsNotMet": "Condizioni di avvio rete non soddisfatte",
"alarm_BatteryCommunicationFailure": "Comunicazione batteria fallita",
"alarm_BatteryDisconnected": "Batteria scollegata",
"alarm_BatteryVoltageTooHigh": "Tensione batteria troppo alta",
"alarm_BatteryVoltageTooLow": "Tensione batteria troppo bassa",
"alarm_BatteryReverseConnected": "Batteria collegata al contrario",
"alarm_LeadAcidTempSensorDisconnected": "Sensore temperatura disconnesso",
"alarm_BatteryTemperatureOutOfRange": "Temperatura batteria anomala",
"alarm_BmsFault": "Guasto BMS",
"alarm_LithiumBatteryOverload": "Sovraccarico batteria litio",
"alarm_BmsCommunicationAbnormal": "Comunicazione BMS anomala",
"alarm_BatterySpdAbnormal": "SPD batteria anomalo",
"alarm_OutputDcComponentBiasAbnormal": "Bias DC anomalo in uscita",
"alarm_DcComponentOverHighOutputVoltage": "Tensione di uscita troppo alta",
"alarm_OffGridOutputVoltageTooLow": "Tensione di uscita troppo bassa",
"alarm_OffGridOutputVoltageTooHigh": "Tensione in uscita troppo alta",
"alarm_OffGridOutputOverCurrent": "Corrente in uscita eccessiva",
"alarm_OffGridOutputOverload": "Sovraccarico uscita off-grid",
"alarm_BalancedCircuitAbnormal": "Circuiti squilibrati anomali",
"alarm_ExportLimitationFailSafe": "Limite esportazione sicurezza",
"alarm_DcBiasAbnormal": "Bias DC anomalo",
"alarm_HighDcComponentOutputCurrent": "Corrente di uscita DC elevata",
"alarm_BusVoltageSamplingAbnormal": "Campionamento tensione bus anomalo",
"alarm_RelayFault": "Guasto Relè",
"alarm_BusVoltageAbnormal": "Tensione Bus Anomala",
"alarm_InternalCommunicationFailure": "Comunicazione Interna Interrotta",
"alarm_TemperatureSensorDisconnected": "Sensore temperatura scollegato",
"alarm_IgbtDriveFault": "Guasto al driver IGBT",
"alarm_EepromError": "Errore EEPROM",
"alarm_AuxiliaryPowerAbnormal": "Alimentazione ausiliaria anomala",
"alarm_DcAcOvercurrentProtection": "Protezione sovracorrente DC/AC",
"alarm_CommunicationProtocolMismatch": "Incompatibilità protocollo comunicazione",
"alarm_DspComFirmwareMismatch": "Incompatibilità firmware DSP/COM",
"alarm_DspSoftwareHardwareMismatch": "Incompatibilità software/hardware DSP",
"alarm_CpldAbnormal": "Anomalia CPLD",
"alarm_RedundancySamplingInconsistent": "Campioni ridondanti incoerenti",
"alarm_PwmPassThroughSignalFailure": "Segnale PWM guasto",
"alarm_AfciSelfTestFailure": "Autotest AFCI fallito",
"alarm_PvCurrentSamplingAbnormal": "Corrente PV Anomala",
"alarm_AcCurrentSamplingAbnormal": "Corrente AC Anomala",
"alarm_BusSoftbootFailure": "Avvio Bus DC Fallito",
"alarm_EpoFault": "Guasto EPO",
"alarm_MonitoringChipBootVerificationFailed": "Verifica avvio chip monitoraggio fallita",
"alarm_BmsCommunicationFailure": "Comunicazione BMS fallita",
"alarm_BmsChargeDischargeFailure": "Guasto Carica/Scarica BMS",
"alarm_BatteryVoltageLow": "Tensione Batteria Bassa",
"alarm_BatteryVoltageHigh": "Tensione Batteria Alta",
"alarm_BatteryTemperatureAbnormal": "Temperatura batteria anomala",
"alarm_BatteryReversed": "Batteria invertita",
"alarm_BatteryOpenCircuit": "Circuiti aperti batteria",
"alarm_BatteryOverloadProtection": "Protezione sovraccarico batteria",
"alarm_Bus2VoltageAbnormal": "Tensione bus2 anomala",
"alarm_BatteryChargeOcp": "Protezione sovraccarico carica",
"alarm_BatteryDischargeOcp": "Protezione sovraccarico scarica",
"alarm_BatterySoftStartFailed": "Avvio batteria fallito",
"alarm_EpsOutputShortCircuited": "Cortocircuito uscita EPS",
"alarm_OffGridBusVoltageLow": "Tensione Bus Fuori Rete Bassa",
"alarm_OffGridTerminalVoltageAbnormal": "Tensione Terminale Fuori Rete Anomala",
"alarm_SoftStartFailed": "Avvio Morbido Fallito",
"alarm_OffGridOutputVoltageAbnormal": "Tensione uscita off-grid anomala",
"alarm_BalancedCircuitSelfTestFailed": "Autotest circuito bilanciato fallito",
"alarm_HighDcComponentOutputVoltage": "Tensione uscita con componente DC elevato",
"alarm_OffGridParallelSignalAbnormal": "Segnale parallelo anomalo",
"alarm_AFCIFault": "Guasto AFCI",
"alarm_GFCIHigh": "Corrente di guasto elevata",
"alarm_PVVoltageHigh": "Tensione PV Elevata",
"alarm_OffGridBusVoltageTooLow": "Tensione bus off-grid troppo bassa"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,101 @@
using InnovEnergy.App.Backend.DataTypes;
namespace InnovEnergy.App.Backend.Services;
public static class BehaviorAnalyzer
{
private const double SolarActiveThresholdKwh = 0.1; // min PV kWh in an hour to count as "solar active"
private const double LowSoCThreshold = 20.0; // % below which battery is considered depleted
/// <summary>
/// Derives behavioral facts from hourly data for the current week only.
/// All computation is pure C# — no AI involved.
/// </summary>
public static BehavioralPattern Analyze(List<HourlyEnergyData> hourlyData)
{
if (hourlyData.Count == 0)
return new BehavioralPattern();
// ── Per-hour averages across the week ──────────────────────────────
// Group by hour-of-day (0-23), average each metric across all days
var byHour = Enumerable.Range(0, 24).Select(h =>
{
var rows = hourlyData.Where(r => r.Hour == h).ToList();
if (rows.Count == 0)
return (Hour: h, AvgPv: 0.0, AvgLoad: 0.0, AvgGridImport: 0.0);
return (
Hour: h,
AvgPv: rows.Average(r => r.PvKwh),
AvgLoad: rows.Average(r => r.LoadKwh),
AvgGridImport: rows.Average(r => r.GridImportKwh)
);
}).ToList();
// ── Peak load hour ─────────────────────────────────────────────────
var peakLoadEntry = byHour.OrderByDescending(h => h.AvgLoad).First();
// ── Peak solar hour and end of solar window ────────────────────────
var peakSolarEntry = byHour.OrderByDescending(h => h.AvgPv).First();
// Solar window: last hour in the day where avg PV > threshold
var solarActiveHours = byHour.Where(h => h.AvgPv >= SolarActiveThresholdKwh).ToList();
var peakSolarEndHour = solarActiveHours.Count > 0
? solarActiveHours.Max(h => h.Hour)
: peakSolarEntry.Hour;
// ── Highest grid-import hour ────────────────────────────────────────
var worstGridEntry = byHour.OrderByDescending(h => h.AvgGridImport).First();
// ── Avoidable grid imports: grid drawn during hours when solar was active ──
// For each actual hourly record: if solar > threshold AND grid import > 0 → avoidable
var avoidableGridKwh = Math.Round(
hourlyData
.Where(r => r.PvKwh >= SolarActiveThresholdKwh && r.GridImportKwh > 0)
.Sum(r => r.GridImportKwh),
1);
// ── Weekday vs weekend average daily load ──────────────────────────
var weekdayDays = hourlyData
.Where(r => !r.IsWeekend)
.GroupBy(r => r.DateTime.Date)
.Select(g => g.Sum(r => r.LoadKwh))
.ToList();
var weekendDays = hourlyData
.Where(r => r.IsWeekend)
.GroupBy(r => r.DateTime.Date)
.Select(g => g.Sum(r => r.LoadKwh))
.ToList();
var weekdayAvg = weekdayDays.Count > 0 ? Math.Round(weekdayDays.Average(), 1) : 0;
var weekendAvg = weekendDays.Count > 0 ? Math.Round(weekendDays.Average(), 1) : 0;
// ── Battery depletion hour ─────────────────────────────────────────
// For each day, find the first evening hour (after 18:00) where SoC < threshold
// Average that hour across days where it occurs
var depletionHours = hourlyData
.Where(r => r.Hour >= 18 && r.BattSoC > 0 && r.BattSoC < LowSoCThreshold)
.GroupBy(r => r.DateTime.Date)
.Select(g => g.OrderBy(r => r.Hour).First().Hour)
.ToList();
var avgDepletedHour = depletionHours.Count > 0 ? (int)Math.Round(depletionHours.Average()) : -1;
var batteryDepletsNight = depletionHours.Count >= 3; // happens on 3+ nights = consistent pattern
return new BehavioralPattern
{
PeakLoadHour = peakLoadEntry.Hour,
AvgPeakLoadKwh = Math.Round(peakLoadEntry.AvgLoad, 2),
PeakSolarHour = peakSolarEntry.Hour,
PeakSolarEndHour = peakSolarEndHour,
AvgPeakSolarKwh = Math.Round(peakSolarEntry.AvgPv, 2),
HighestGridImportHour = worstGridEntry.Hour,
AvgGridImportAtPeakHour = Math.Round(worstGridEntry.AvgGridImport, 2),
AvoidableGridKwh = avoidableGridKwh,
WeekdayAvgDailyLoad = weekdayAvg,
WeekendAvgDailyLoad = weekendAvg,
AvgBatteryDepletedHour = avgDepletedHour,
BatteryDepletesOvernight = batteryDepletsNight,
};
}
}

View File

@ -0,0 +1,297 @@
using System.Collections.Concurrent;
using Flurl.Http;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using Newtonsoft.Json;
using System.Text.RegularExpressions;
namespace InnovEnergy.App.Backend.Services;
/// <summary>
/// Calls Mistral AI to generate plain-English diagnostics for errors/warnings.
/// Caches responses in-memory keyed by error description so the same
/// error code is only sent to the API once.
/// </summary>
public static class DiagnosticService
{
private static string _apiKey = "";
/// <summary>In-memory cache: errorDescription → parsed response.</summary>
private static readonly ConcurrentDictionary<string, DiagnosticResponse> Cache = new();
/// <summary>Pre-generated translations keyed by language code → alarm key → response.</summary>
private static readonly Dictionary<string, IReadOnlyDictionary<string, DiagnosticResponse>> Translations = new();
// ── initialisation ──────────────────────────────────────────────
public static void Initialize()
{
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
Console.Error.WriteLine("[DiagnosticService] MISTRAL_API_KEY not set AI diagnostics disabled.");
else
_apiKey = apiKey;
// Load pre-generated translation files (en, de, fr, it) if available
// en.json is generated by generate_alarm_translations.py after the review campaign
var resourcesDir = Path.Combine(AppContext.BaseDirectory, "Resources");
foreach (var lang in new[] { "en", "de", "fr", "it" })
{
var file = Path.Combine(resourcesDir, $"AlarmTranslations.{lang}.json");
if (!File.Exists(file)) continue;
try
{
var json = File.ReadAllText(file);
var raw = JsonConvert.DeserializeObject<Dictionary<string, DiagnosticResponse>>(json);
if (raw is not null)
{
Translations[lang] = raw;
Console.WriteLine($"[DiagnosticService] Loaded {raw.Count} {lang} translations.");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DiagnosticService] Failed to load AlarmTranslations.{lang}.json: {ex.Message}");
}
}
Console.WriteLine("[DiagnosticService] initialised.");
}
public static bool IsEnabled => !string.IsNullOrEmpty(_apiKey);
// ── public entry-point ──────────────────────────────────────────
private static string LanguageName(string code) => code switch
{
"de" => "German",
"fr" => "French",
"it" => "Italian",
_ => "English"
};
/// <summary>Converts "AbnormalGridVoltage" → "Abnormal Grid Voltage".</summary>
private static string SplitCamelCase(string name) =>
Regex.Replace(name, @"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ").Trim();
/// <summary>
/// Returns a diagnosis for <paramref name="errorDescription"/> in the given language.
/// For English: checks the static AlarmKnowledgeBase first, then in-memory cache, then Mistral AI.
/// For other languages: skips the knowledge base (English-only) and goes directly to Mistral AI.
/// Cache is keyed by (errorDescription, language) so each language is cached separately.
/// </summary>
public static async Task<DiagnosticResponse?> DiagnoseAsync(Int64 installationId, string errorDescription, string language = "en")
{
var cacheKey = $"{errorDescription}|{language}";
// 1. For English: check the static knowledge base first (no API call needed)
if (language == "en")
{
var knownDiagnosis = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription);
if (knownDiagnosis is not null)
{
Console.WriteLine($"[DiagnosticService] Knowledge base hit (en): {errorDescription}");
// Return a new instance with Name set — avoids mutating the shared static dictionary
return new DiagnosticResponse
{
Name = SplitCamelCase(errorDescription),
Explanation = knownDiagnosis.Explanation,
Causes = knownDiagnosis.Causes,
NextSteps = knownDiagnosis.NextSteps,
};
}
}
// 2. For non-English: check pre-generated translation files (no API call needed)
if (language != "en" && Translations.TryGetValue(language, out var langDict))
{
if (langDict.TryGetValue(errorDescription, out var translatedDiagnosis))
{
Console.WriteLine($"[DiagnosticService] Pre-generated translation hit ({language}): {errorDescription}");
return translatedDiagnosis;
}
}
// 3. If AI is not enabled, we can't proceed further
if (!IsEnabled) return null;
// 4. Check in-memory cache for previously fetched AI diagnoses
if (Cache.TryGetValue(cacheKey, out var cached))
return cached;
// 4. Gather context from the DB for AI prompt
var installation = Db.GetInstallationById(installationId);
if (installation is null) return null;
var productName = ((ProductType)installation.Product).ToString();
var recentDescriptions = Db.Errors
.Where(e => e.InstallationId == installationId)
.OrderByDescending(e => e.Date)
.ThenByDescending(e => e.Time)
.ToList() // materialize before LINQ-to-objects ops
.Select(e => e.Description)
.Distinct() // deduplicate — same error repeated adds no signal
.Take(5)
.ToList();
// 5. Build prompt and call Mistral API (only for unknown alarms)
Console.WriteLine($"[DiagnosticService] Calling Mistral for unknown alarm: {errorDescription} ({language})");
var prompt = BuildPrompt(errorDescription, productName, recentDescriptions, language);
var response = await CallMistralAsync(prompt);
if (response is null) return null;
// 6. Store in cache for future requests
Cache.TryAdd(cacheKey, response);
return response;
}
// ── test helper (no DB dependency) ─────────────────────────────
/// <summary>
/// Returns a diagnosis from the static knowledge base (English) or pre-generated
/// translations (other languages). Returns null if not found in either.
/// </summary>
public static DiagnosticResponse? TryGetTranslation(string errorDescription, string language)
{
// Check JSON translations first (en.json exists after review campaign)
if (Translations.TryGetValue(language, out var langDict) &&
langDict.TryGetValue(errorDescription, out var translated))
return translated;
// Fallback: English from compiled AlarmKnowledgeBase.cs (until en.json is deployed)
if (language == "en")
{
var kb = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription);
if (kb is null) return null;
return new DiagnosticResponse
{
Name = SplitCamelCase(errorDescription),
Explanation = kb.Explanation,
Causes = kb.Causes,
NextSteps = kb.NextSteps,
};
}
return null;
}
/// <summary>
/// Calls Mistral directly with a generic prompt. For testing only - no DB lookup.
/// </summary>
public static async Task<DiagnosticResponse?> TestCallMistralAsync(string errorDescription, string language = "en")
{
if (!IsEnabled) return null;
var cacheKey = $"{errorDescription}|{language}";
// Check cache first
if (Cache.TryGetValue(cacheKey, out var cached))
return cached;
var prompt = BuildPrompt(errorDescription, "SodioHome", new List<string>(), language);
var response = await CallMistralAsync(prompt);
if (response is not null)
Cache.TryAdd(cacheKey, response);
return response;
}
// ── prompt ──────────────────────────────────────────────────────
private static string BuildPrompt(string errorDescription, string productName, List<string> recentErrors, string language = "en")
{
var recentList = recentErrors.Count > 0
? string.Join(", ", recentErrors)
: "none";
return $@"You are a technician for {productName} battery energy storage systems.
These are sodium-ion BESS units with a BMS, PV inverter, and grid inverter.
Error: {errorDescription}
Other recent errors: {recentList}
Explain for a non-technical homeowner. Keep it very short and simple:
- name: 2-5 word display title for this alarm
- explanation: 1 short sentence, no jargon
- causes: 2-3 bullet points, plain language
- nextSteps: 2-3 simple action items a homeowner can understand
IMPORTANT: Write all text values in {LanguageName(language)}. Reply with ONLY valid JSON, no markdown:
{{""name"":""short title"",""explanation"":""1 short sentence"",""causes"":[""...""],""nextSteps"":[""...""]}}
";
}
// ── Mistral HTTP call ────────────────────────────────────────────
private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions";
private static async Task<DiagnosticResponse?> CallMistralAsync(string userPrompt)
{
try
{
var requestBody = new
{
model = "mistral-small-latest", // cost-efficient, fast; swap to "mistral-large-latest" if quality needs tuning
messages = new[]
{
new { role = "user", content = userPrompt }
},
max_tokens = 400,
temperature = 0.2 // low temperature for factual consistency
};
var responseText = await MistralUrl
.WithHeader("Authorization", $"Bearer {_apiKey}")
.PostJsonAsync(requestBody)
.ReceiveString();
// parse Mistral envelope (same structure as OpenAI)
var envelope = JsonConvert.DeserializeObject<dynamic>(responseText);
var content = (string?) envelope?.choices?[0]?.message?.content;
if (string.IsNullOrWhiteSpace(content))
{
Console.Error.WriteLine("[DiagnosticService] Mistral returned empty content.");
return null;
}
// strip markdown code fences if Mistral wraps the JSON in ```json ... ```
var json = content.Trim();
if (json.StartsWith("```"))
{
var firstNewline = json.IndexOf('\n');
if (firstNewline >= 0) json = json[(firstNewline + 1)..];
if (json.EndsWith("```")) json = json[..^3];
json = json.Trim();
}
// parse the JSON the model produced
var diagnostic = JsonConvert.DeserializeObject<DiagnosticResponse>(json);
return diagnostic;
}
catch (FlurlHttpException httpEx)
{
Console.Error.WriteLine($"[DiagnosticService] HTTP error {httpEx.StatusCode}: {httpEx.Message}");
return null;
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DiagnosticService] {ex.Message}");
return null;
}
}
}
// ── config / response models ────────────────────────────────────────────────
public class DiagnosticResponse
{
public string Name { get; set; } = "";
public string Explanation { get; set; } = "";
public IReadOnlyList<string> Causes { get; set; } = Array.Empty<string>();
public IReadOnlyList<string> NextSteps { get; set; } = Array.Empty<string>();
}

View File

@ -0,0 +1,186 @@
using ClosedXML.Excel;
using InnovEnergy.App.Backend.DataTypes;
namespace InnovEnergy.App.Backend.Services;
public static class ExcelDataParser
{
// Column headers from the ESS Link Cloud Excel export
private const string ColDateTime = "Data time";
private const string ColPvToday = "PV Generated Energy Today";
private const string ColLoadToday = "Load Consumption Today";
private const string ColGridImportToday = "Purchased Energy Today";
private const string ColGridExportToday = "Feed in energy Today";
private const string ColBattChargedToday = "Daily Battery Charged";
private const string ColBattDischargedToday = "Battery Discharged Today";
private const string ColBattSoC = "Battery 1 SoC"; // instantaneous %
/// <summary>
/// Parses an ESS Link Cloud Excel export file and returns one DailyEnergyData per day.
/// Takes the last row of each day (where "Today" cumulative values are highest).
/// </summary>
public static List<DailyEnergyData> Parse(string filePath)
{
if (!File.Exists(filePath))
throw new FileNotFoundException($"Excel file not found: {filePath}");
using var workbook = new XLWorkbook(filePath);
var worksheet = workbook.Worksheet(1);
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 0;
if (lastRow < 2)
throw new InvalidOperationException("Excel file has no data rows.");
// Find column indices by header name (row 1)
var headerRow = worksheet.Row(1);
var colMap = new Dictionary<string, int>();
for (var col = 1; col <= worksheet.LastColumnUsed()?.ColumnNumber(); col++)
{
var header = headerRow.Cell(col).GetString().Trim();
if (!string.IsNullOrEmpty(header))
colMap[header] = col;
}
// Validate required columns exist
var requiredCols = new[] { ColDateTime, ColPvToday, ColLoadToday, ColGridImportToday, ColGridExportToday, ColBattChargedToday, ColBattDischargedToday };
foreach (var rc in requiredCols)
{
if (!colMap.ContainsKey(rc))
throw new InvalidOperationException($"Required column '{rc}' not found in Excel file. Available: {string.Join(", ", colMap.Keys)}");
}
// Read all rows, group by date, keep last row per day
var dailyLastRows = new SortedDictionary<string, DailyEnergyData>();
for (var row = 2; row <= lastRow; row++)
{
var dateTimeStr = worksheet.Row(row).Cell(colMap[ColDateTime]).GetString().Trim();
if (string.IsNullOrEmpty(dateTimeStr)) continue;
// Extract date portion (first 10 chars: "2026-02-10")
var date = dateTimeStr.Length >= 10 ? dateTimeStr[..10] : dateTimeStr;
var data = new DailyEnergyData
{
Date = date,
PvProduction = GetDouble(worksheet, row, colMap[ColPvToday]),
LoadConsumption = GetDouble(worksheet, row, colMap[ColLoadToday]),
GridImport = GetDouble(worksheet, row, colMap[ColGridImportToday]),
GridExport = GetDouble(worksheet, row, colMap[ColGridExportToday]),
BatteryCharged = GetDouble(worksheet, row, colMap[ColBattChargedToday]),
BatteryDischarged = GetDouble(worksheet, row, colMap[ColBattDischargedToday]),
};
// Always overwrite — last row of the day has the final cumulative values
dailyLastRows[date] = data;
}
Console.WriteLine($"[ExcelDataParser] Parsed {dailyLastRows.Count} days from {filePath}");
return dailyLastRows.Values.ToList();
}
/// <summary>
/// Parses hourly energy snapshots from the xlsx.
/// For each hour of each day, finds the row nearest HH:00:00 and records the
/// cumulative "Today" values at that moment. The caller (BehaviorAnalyzer) then
/// diffs consecutive snapshots to get per-hour energy.
/// </summary>
public static List<HourlyEnergyData> ParseHourly(string filePath)
{
if (!File.Exists(filePath))
throw new FileNotFoundException($"Excel file not found: {filePath}");
using var workbook = new XLWorkbook(filePath);
var worksheet = workbook.Worksheet(1);
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 0;
if (lastRow < 2)
throw new InvalidOperationException("Excel file has no data rows.");
// Build column map
var headerRow = worksheet.Row(1);
var colMap = new Dictionary<string, int>();
for (var col = 1; col <= worksheet.LastColumnUsed()?.ColumnNumber(); col++)
{
var header = headerRow.Cell(col).GetString().Trim();
if (!string.IsNullOrEmpty(header))
colMap[header] = col;
}
// SoC column is optional — not all exports include it
var hasSoC = colMap.ContainsKey(ColBattSoC);
// Read all rows into memory as (DateTime, cumulative values) pairs
var rawRows = new List<(DateTime Dt, double Pv, double Load, double GridIn, double BattChg, double BattDis, double SoC)>();
for (var row = 2; row <= lastRow; row++)
{
var dtStr = worksheet.Row(row).Cell(colMap[ColDateTime]).GetString().Trim();
if (string.IsNullOrEmpty(dtStr)) continue;
if (!DateTime.TryParse(dtStr, out var dt)) continue;
rawRows.Add((
dt,
GetDouble(worksheet, row, colMap[ColPvToday]),
GetDouble(worksheet, row, colMap[ColLoadToday]),
GetDouble(worksheet, row, colMap[ColGridImportToday]),
GetDouble(worksheet, row, colMap[ColBattChargedToday]),
GetDouble(worksheet, row, colMap[ColBattDischargedToday]),
hasSoC ? GetDouble(worksheet, row, colMap[ColBattSoC]) : 0
));
}
if (rawRows.Count == 0) return new List<HourlyEnergyData>();
// For each calendar hour that exists in the data, find the nearest row to HH:00:00
// Group rows by (date, hour) and pick the one closest to the round hour
var byHour = rawRows
.GroupBy(r => new DateTime(r.Dt.Year, r.Dt.Month, r.Dt.Day, r.Dt.Hour, 0, 0))
.OrderBy(g => g.Key)
.Select(g =>
{
var roundHour = g.Key;
var nearest = g.OrderBy(r => Math.Abs((r.Dt - roundHour).TotalSeconds)).First();
return (RoundHour: roundHour, Row: nearest);
})
.ToList();
// Diff consecutive snapshots within the same day to get per-hour energy
var result = new List<HourlyEnergyData>();
for (var i = 1; i < byHour.Count; i++)
{
var prev = byHour[i - 1];
var curr = byHour[i];
// Only diff within the same day — don't carry over across midnight
if (curr.RoundHour.Date != prev.RoundHour.Date) continue;
// Cumulative "Today" values reset at midnight, so diff is always >= 0 within a day
result.Add(new HourlyEnergyData
{
DateTime = curr.RoundHour,
Hour = curr.RoundHour.Hour,
DayOfWeek = curr.RoundHour.DayOfWeek.ToString(),
IsWeekend = curr.RoundHour.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
PvKwh = Math.Max(0, Math.Round(curr.Row.Pv - prev.Row.Pv, 3)),
LoadKwh = Math.Max(0, Math.Round(curr.Row.Load - prev.Row.Load, 3)),
GridImportKwh = Math.Max(0, Math.Round(curr.Row.GridIn - prev.Row.GridIn, 3)),
BatteryChargedKwh = Math.Max(0, Math.Round(curr.Row.BattChg - prev.Row.BattChg, 3)),
BatteryDischargedKwh = Math.Max(0, Math.Round(curr.Row.BattDis - prev.Row.BattDis, 3)),
BattSoC = curr.Row.SoC,
});
}
Console.WriteLine($"[ExcelDataParser] Parsed {result.Count} hourly records from {filePath}");
return result;
}
private static double GetDouble(IXLWorksheet ws, int row, int col)
{
var cell = ws.Row(row).Cell(col);
if (cell.IsEmpty()) return 0;
return cell.TryGetValue<double>(out var val) ? Math.Round(val, 4) : 0;
}
}

View File

@ -0,0 +1,446 @@
using System.Text.Json;
using InnovEnergy.App.Backend.DataTypes;
using InnovEnergy.Lib.Mailer;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
namespace InnovEnergy.App.Backend.Services;
public static class ReportEmailService
{
/// <summary>
/// Sends the weekly report as a nicely formatted HTML email in the user's language.
/// Uses MailKit directly (same config as existing Mailer library) but with HTML support.
/// </summary>
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en")
{
var strings = GetStrings(language);
var subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})";
var html = BuildHtmlEmail(report, strings);
var config = await ReadMailerConfig();
var from = new MailboxAddress(config.SenderName, config.SenderAddress);
var to = new MailboxAddress(recipientEmail, recipientEmail);
var msg = new MimeMessage
{
From = { from },
To = { to },
Subject = subject,
Body = new TextPart("html") { Text = html }
};
Console.WriteLine($"[ReportEmailService] SMTP: {config.SmtpUsername}@{config.SmtpServerUrl}:{config.SmtpPort}");
using var smtp = new SmtpClient();
await smtp.ConnectAsync(config.SmtpServerUrl, config.SmtpPort, SecureSocketOptions.StartTls);
await smtp.AuthenticateAsync(config.SmtpUsername, config.SmtpPassword);
await smtp.SendAsync(msg);
await smtp.DisconnectAsync(true);
Console.WriteLine($"[ReportEmailService] Report sent to {recipientEmail}");
}
private static async Task<MailerConfig> ReadMailerConfig()
{
await using var fileStream = File.OpenRead(MailerConfig.DefaultFile);
var config = await JsonSerializer.DeserializeAsync<MailerConfig>(fileStream);
return config ?? throw new InvalidOperationException("Failed to read MailerConfig.json");
}
// ── Translation strings ─────────────────────────────────────────────────
private record EmailStrings(
string Title,
string Insights,
string Summary,
string SavingsHeader,
string DailyBreakdown,
string Metric,
string ThisWeek,
string LastWeek,
string Change,
string PvProduction,
string Consumption,
string GridImport,
string GridExport,
string BatteryInOut,
string SolarEnergyUsed,
string StayedAtHome,
string EstMoneySaved,
string AtRate,
string SolarCoverage,
string FromSolar,
string BatteryEff,
string OutVsIn,
string Day,
string Load,
string GridIn,
string GridOut,
string BattInOut,
string Footer
);
private static EmailStrings GetStrings(string language) => language switch
{
"de" => new EmailStrings(
Title: "Wöchentlicher Leistungsbericht",
Insights: "Wöchentliche Erkenntnisse",
Summary: "Wöchentliche Zusammenfassung",
SavingsHeader: "Ihre Ersparnisse diese Woche",
DailyBreakdown: "Tägliche Aufschlüsselung (kWh)",
Metric: "Kennzahl",
ThisWeek: "Diese Woche",
LastWeek: "Letzte Woche",
Change: "Änderung",
PvProduction: "PV-Produktion",
Consumption: "Verbrauch",
GridImport: "Netzbezug",
GridExport: "Netzeinspeisung",
BatteryInOut: "Batterie Laden / Entladen",
SolarEnergyUsed: "Energie gespart",
StayedAtHome: "Solar + Batterie, nicht vom Netz",
EstMoneySaved: "Geschätzte Ersparnis",
AtRate: "bei 0.39 CHF/kWh",
SolarCoverage: "Eigenversorgung",
FromSolar: "aus Solar + Batterie",
BatteryEff: "Batterie-Eff.",
OutVsIn: "Entladung vs. Ladung",
Day: "Tag",
Load: "Last",
GridIn: "Netz Ein",
GridOut: "Netz Aus",
BattInOut: "Batt. Laden/Entl.",
Footer: "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"
),
"fr" => new EmailStrings(
Title: "Rapport de performance hebdomadaire",
Insights: "Aperçus de la semaine",
Summary: "Résumé de la semaine",
SavingsHeader: "Vos économies cette semaine",
DailyBreakdown: "Détail quotidien (kWh)",
Metric: "Indicateur",
ThisWeek: "Cette semaine",
LastWeek: "Semaine dernière",
Change: "Variation",
PvProduction: "Production PV",
Consumption: "Consommation",
GridImport: "Import réseau",
GridExport: "Export réseau",
BatteryInOut: "Batterie Charge / Décharge",
SolarEnergyUsed: "Énergie économisée",
StayedAtHome: "solaire + batterie, non achetée au réseau",
EstMoneySaved: "Économies estimées",
AtRate: "à 0.39 CHF/kWh",
SolarCoverage: "Autosuffisance",
FromSolar: "du solaire + batterie",
BatteryEff: "Eff. batterie",
OutVsIn: "décharge vs charge",
Day: "Jour",
Load: "Charge",
GridIn: "Réseau Ent.",
GridOut: "Réseau Sor.",
BattInOut: "Batt. Ch./Déch.",
Footer: "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"
),
"it" => new EmailStrings(
Title: "Rapporto settimanale delle prestazioni",
Insights: "Approfondimenti settimanali",
Summary: "Riepilogo settimanale",
SavingsHeader: "I tuoi risparmi questa settimana",
DailyBreakdown: "Dettaglio giornaliero (kWh)",
Metric: "Metrica",
ThisWeek: "Questa settimana",
LastWeek: "La settimana scorsa",
Change: "Variazione",
PvProduction: "Produzione PV",
Consumption: "Consumo",
GridImport: "Import dalla rete",
GridExport: "Export nella rete",
BatteryInOut: "Batteria Carica / Scarica",
SolarEnergyUsed: "Energia risparmiata",
StayedAtHome: "solare + batteria, non acquistata dalla rete",
EstMoneySaved: "Risparmio stimato",
AtRate: "a 0.39 CHF/kWh",
SolarCoverage: "Autosufficienza",
FromSolar: "da solare + batteria",
BatteryEff: "Eff. batteria",
OutVsIn: "scarica vs carica",
Day: "Giorno",
Load: "Carico",
GridIn: "Rete Ent.",
GridOut: "Rete Usc.",
BattInOut: "Batt. Car./Sc.",
Footer: "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"
),
_ => new EmailStrings(
Title: "Weekly Performance Report",
Insights: "Weekly Insights",
Summary: "Weekly Summary",
SavingsHeader: "Your Savings This Week",
DailyBreakdown: "Daily Breakdown (kWh)",
Metric: "Metric",
ThisWeek: "This Week",
LastWeek: "Last Week",
Change: "Change",
PvProduction: "PV Production",
Consumption: "Consumption",
GridImport: "Grid Import",
GridExport: "Grid Export",
BatteryInOut: "Battery Charge / Discharge",
SolarEnergyUsed: "Energy Saved",
StayedAtHome: "solar + battery, not bought from grid",
EstMoneySaved: "Est. Money Saved",
AtRate: "at 0.39 CHF/kWh",
SolarCoverage: "Self-Sufficiency",
FromSolar: "from solar + battery",
BatteryEff: "Battery Eff.",
OutVsIn: "discharge vs charge",
Day: "Day",
Load: "Load",
GridIn: "Grid In",
GridOut: "Grid Out",
BattInOut: "Batt. Ch./Dis.",
Footer: "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>"
)
};
// ── HTML email template ─────────────────────────────────────────────
public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en")
=> BuildHtmlEmail(r, GetStrings(language));
private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s)
{
var cur = r.CurrentWeek;
var prev = r.PreviousWeek;
// Parse AI insight into <li> bullet points (split on newlines, strip leading "- " or "1. ")
var insightLines = r.AiInsight
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", ""))
.Where(l => l.Length > 0)
.ToList();
var insightHtml = insightLines.Count > 1
? "<ul style=\"margin:0;padding-left:20px\">" +
string.Join("", insightLines.Select(l => $"<li style=\"margin-bottom:8px;line-height:1.6\">{FormatInsightLine(l)}</li>")) +
"</ul>"
: $"<p style=\"margin:0;line-height:1.6\">{FormatInsightLine(r.AiInsight)}</p>";
// Detect which components are present across all daily data
var showPv = r.DailyData.Any(d => d.PvProduction > 0.1);
var showGrid = r.DailyData.Any(d => d.GridImport > 0.1);
// Daily rows — colorful bar chart (pixel widths, email-safe)
// Scale each day's bars so their combined total always fills maxBarPx (right-edge aligned).
// This replicates the web page's CSS flexbox flex-shrink:1 behaviour.
const int maxBarPx = 400;
var dailyRows = "";
foreach (var d in r.DailyData)
{
var dayName = DateTime.Parse(d.Date).ToString("ddd dd.MM");
var isCurrentWeek = string.Compare(d.Date, r.PeriodStart, StringComparison.Ordinal) >= 0;
var opacity = isCurrentWeek ? "1" : "0.55";
var fontWeight = isCurrentWeek ? "bold" : "normal";
var dayTotal = (showPv ? d.PvProduction : 0) + d.LoadConsumption + (showGrid ? d.GridImport : 0);
if (dayTotal < 0.1) dayTotal = 0.1;
var pvPx = showPv ? (int)(d.PvProduction / dayTotal * maxBarPx) : 0;
var ldPx = (int)(d.LoadConsumption / dayTotal * maxBarPx);
var giPx = showGrid ? (int)(d.GridImport / dayTotal * maxBarPx) : 0;
var pvSpan = showPv ? $@"<span style=""display:inline-block;height:14px;background:#f39c12;width:{pvPx}px;border-radius:2px 0 0 2px""></span>" : "";
var gridSpan = showGrid ? $@"<span style=""display:inline-block;height:14px;background:#e74c3c;width:{giPx}px;border-radius:0 2px 2px 0;margin-left:2px""></span>" : "";
var ldRadius = (!showPv ? "border-radius:2px 0 0 2px;" : "") + (!showGrid ? "border-radius:0 2px 2px 0;" : "");
var valueText = (showPv ? $"PV {d.PvProduction:F1} | " : "")
+ $"{s.Load} {d.LoadConsumption:F1}"
+ (showGrid ? $" | {s.GridIn} {d.GridImport:F1}" : "")
+ " kWh";
dailyRows += $@"
<tr style=""opacity:{opacity};border-bottom:1px solid #f0f0f0"">
<td style=""padding:6px 8px;font-size:12px;font-weight:{fontWeight};white-space:nowrap;width:80px;vertical-align:top;padding-top:10px"">{dayName}</td>
<td style=""padding:4px 8px"">
<div style=""font-size:10px;color:#888;margin-bottom:3px;text-align:right"">{valueText}</div>
<div style=""height:14px;line-height:14px;font-size:0;white-space:nowrap;width:{maxBarPx}px"">{pvSpan}<span style=""display:inline-block;height:14px;background:#3498db;width:{ldPx}px;{ldRadius}margin-left:{(showPv ? 2 : 0)}px""></span>{gridSpan}</div>
</td>
</tr>";
}
// Week-over-week comparison rows
var comparisonHtml = prev != null
? $@"
<tr>
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.PvProduction}</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalPvProduction:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalPvProduction:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(r.PvChangePercent)}"">{FormatChange(r.PvChangePercent)}</td>
</tr>
<tr>
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.Consumption}</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalConsumption:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalConsumption:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(-r.ConsumptionChangePercent)}"">{FormatChange(r.ConsumptionChangePercent)}</td>
</tr>
<tr>
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridImport}</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalGridImport:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalGridImport:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(-r.GridImportChangePercent)}"">{FormatChange(r.GridImportChangePercent)}</td>
</tr>
<tr>
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridExport}</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalGridExport:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalGridExport:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right""></td>
</tr>
<tr>
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.BatteryInOut}</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalBatteryCharged:F1}/{prev.TotalBatteryDischarged:F1} kWh</td>
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right""></td>
</tr>"
: $@"
<tr><td style=""padding:8px 12px"">{s.PvProduction}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalPvProduction:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.Consumption}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalConsumption:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.GridImport}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridImport:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.GridExport}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridExport:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.BatteryInOut}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh</td></tr>";
var comparisonHeaders = prev != null
? $@"<th style=""padding:8px 12px;text-align:right"">{s.ThisWeek}</th>
<th style=""padding:8px 12px;text-align:right"">{s.LastWeek}</th>
<th style=""padding:8px 12px;text-align:right"">{s.Change}</th>"
: $@"<th style=""padding:8px 12px;text-align:right"">{s.ThisWeek}</th>";
return $@"
<!DOCTYPE html>
<html>
<head><meta charset=""utf-8""></head>
<body style=""margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333"">
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""background:#f4f4f4;padding:20px 0"">
<tr><td align=""center"">
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08)"">
<!-- Header -->
<tr>
<td style=""background:#2c3e50;padding:24px 30px;color:#ffffff"">
<div style=""font-size:20px;font-weight:bold"">{s.Title}</div>
<div style=""font-size:14px;margin-top:6px;opacity:0.9"">{r.InstallationName}</div>
<div style=""font-size:13px;margin-top:2px;opacity:0.7"">{r.PeriodStart} {r.PeriodEnd}</div>
</td>
</tr>
<!-- Weekly Insights (top) -->
<tr>
<td style=""padding:24px 30px 0"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Insights}</div>
<div style=""background:#fef9e7;border-left:4px solid #f39c12;padding:14px 18px;border-radius:0 6px 6px 0;font-size:14px;color:#333"">
{insightHtml}
</div>
</td>
</tr>
<!-- Weekly Totals -->
<tr>
<td style=""padding:24px 30px"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Summary}</div>
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px"">
<tr style=""background:#f8f9fa"">
<th style=""padding:8px 12px;text-align:left"">{s.Metric}</th>
{comparisonHeaders}
</tr>
{comparisonHtml}
</table>
</td>
</tr>
<!-- Key Ratios -->
<tr>
<td style=""padding:0 30px 24px"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.SavingsHeader}</div>
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
<tr>
{SavingsBox(s.SolarEnergyUsed, $"{r.TotalEnergySaved:F1} kWh", s.StayedAtHome, "#27ae60")}
{SavingsBox(s.EstMoneySaved, $"~{r.TotalSavingsCHF:F0} CHF", s.AtRate, "#2980b9")}
{SavingsBox(s.SolarCoverage, $"{r.SelfSufficiencyPercent:F0}%", s.FromSolar, "#8e44ad")}
{SavingsBox(s.BatteryEff, $"{r.BatteryEfficiencyPercent:F0}%", s.OutVsIn, "#e67e22")}
</tr>
</table>
</td>
</tr>
<!-- Daily Breakdown (bar chart) -->
<tr>
<td style=""padding:0 30px 24px"">
<div style=""font-size:16px;font-weight:bold;margin-bottom:8px;color:#2c3e50"">{s.DailyBreakdown}</div>
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px;font-size:13px"">
<!-- Legend -->
<tr style=""background:#f8f9fa"">
<td colspan=""2"" style=""padding:8px 10px;font-size:12px"">
{(showPv ? @$"<span style=""display:inline-block;width:10px;height:10px;background:#f39c12;border-radius:2px;margin-right:4px""></span>PV &nbsp;&nbsp;" : "")}
<span style=""display:inline-block;width:10px;height:10px;background:#3498db;border-radius:2px;margin-right:4px""></span>{s.Load} &nbsp;&nbsp;
{(showGrid ? @$"<span style=""display:inline-block;width:10px;height:10px;background:#e74c3c;border-radius:2px;margin-right:4px""></span>{s.GridIn}" : "")}
</td>
</tr>
{dailyRows}
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
{s.Footer}
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>";
}
private static string SavingsBox(string label, string value, string subtitle, string color) =>
$@"<td width=""25%"" style=""text-align:center"">
<div style=""background:#f8f9fa;border-radius:6px;padding:12px 4px"">
<div style=""font-size:22px;font-weight:bold;color:{color}"">{value}</div>
<div style=""font-size:12px;font-weight:bold;color:#444;margin-top:4px"">{label}</div>
<div style=""font-size:10px;color:#888;margin-top:2px"">{subtitle}</div>
</div>
</td>";
// Bolds "Title" before first colon, and numbers+units in the rest
private static string FormatInsightLine(string line)
{
var colonIdx = line.IndexOf(':');
string result;
if (colonIdx > 0)
{
var title = line[..colonIdx];
var rest = line[colonIdx..]; // includes the colon
result = $"<strong>{title}</strong>{rest}";
}
else
{
result = line;
}
// Bold all numbers: time ranges (14:0018:00), times (09:00), decimals, integers
result = System.Text.RegularExpressions.Regex.Replace(
result,
@"(\d{1,2}:\d{2}(?:[\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)",
"<strong>$1</strong>");
return result;
}
private static string FormatChange(double pct) =>
pct == 0 ? "—" : pct > 0 ? $"+{pct:F1}%" : $"{pct:F1}%";
private static string ChangeColor(double pct) =>
pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888";
}

View File

@ -0,0 +1,340 @@
using Flurl.Http;
using InnovEnergy.App.Backend.DataTypes;
using Newtonsoft.Json;
namespace InnovEnergy.App.Backend.Services;
public static class WeeklyReportService
{
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
/// <summary>
/// Generates a full weekly report for the given installation.
/// Cache is invalidated automatically when the xlsx file is newer than the cache.
/// To force regeneration (e.g. after a prompt change), simply delete the cache files.
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName, string language = "en")
{
var xlsxPath = TmpReportDir + installationId + ".xlsx";
var cachePath = TmpReportDir + $"{installationId}_{language}.cache.json";
// Use cached report if xlsx hasn't changed since cache was written
if (File.Exists(cachePath) && File.Exists(xlsxPath))
{
var xlsxModified = File.GetLastWriteTimeUtc(xlsxPath);
var cacheModified = File.GetLastWriteTimeUtc(cachePath);
if (cacheModified > xlsxModified)
{
try
{
var cached = JsonConvert.DeserializeObject<WeeklyReportResponse>(
await File.ReadAllTextAsync(cachePath));
if (cached != null)
{
Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language}).");
return cached;
}
}
catch
{
// Cache corrupt — regenerate
}
}
}
// Parse both daily summaries and hourly intervals from the same xlsx
var allDays = ExcelDataParser.Parse(xlsxPath);
var allHourly = ExcelDataParser.ParseHourly(xlsxPath);
var report = await GenerateReportFromDataAsync(allDays, allHourly, installationName, language);
// Write cache
try
{
await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report));
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}");
}
return report;
}
/// <summary>
/// Core report generation. Accepts both daily summaries and hourly intervals.
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
List<DailyEnergyData> allDays,
List<HourlyEnergyData> allHourly,
string installationName,
string language = "en")
{
// Sort by date
allDays = allDays.OrderBy(d => d.Date).ToList();
// Split into previous week and current week (daily)
List<DailyEnergyData> previousWeekDays;
List<DailyEnergyData> currentWeekDays;
if (allDays.Count > 7)
{
previousWeekDays = allDays.Take(allDays.Count - 7).ToList();
currentWeekDays = allDays.Skip(allDays.Count - 7).ToList();
}
else
{
previousWeekDays = new List<DailyEnergyData>();
currentWeekDays = allDays;
}
// Restrict hourly data to current week only for behavioral analysis
var currentWeekStart = DateTime.Parse(currentWeekDays.First().Date);
var currentHourlyData = allHourly.Where(h => h.DateTime.Date >= currentWeekStart.Date).ToList();
var currentSummary = Summarize(currentWeekDays);
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
// Key ratios for current week
var selfSufficiency = currentSummary.TotalConsumption > 0
? Math.Round((currentSummary.TotalConsumption - currentSummary.TotalGridImport) / currentSummary.TotalConsumption * 100, 1)
: 0;
var selfConsumption = currentSummary.TotalPvProduction > 0
? Math.Round((currentSummary.TotalPvProduction - currentSummary.TotalGridExport) / currentSummary.TotalPvProduction * 100, 1)
: 0;
var batteryEfficiency = currentSummary.TotalBatteryCharged > 0
? Math.Round(currentSummary.TotalBatteryDischarged / currentSummary.TotalBatteryCharged * 100, 1)
: 0;
var gridDependency = currentSummary.TotalConsumption > 0
? Math.Round(currentSummary.TotalGridImport / currentSummary.TotalConsumption * 100, 1)
: 0;
// Week-over-week changes
var pvChange = PercentChange(previousSummary?.TotalPvProduction, currentSummary.TotalPvProduction);
var consumptionChange = PercentChange(previousSummary?.TotalConsumption, currentSummary.TotalConsumption);
var gridImportChange = PercentChange(previousSummary?.TotalGridImport, currentSummary.TotalGridImport);
// Behavioral pattern from hourly data (pure C# — no AI)
var behavior = BehaviorAnalyzer.Analyze(currentHourlyData);
// Pre-computed savings — single source of truth for UI and AI
const double ElectricityPriceCHF = 0.39;
var totalEnergySaved = Math.Round(currentSummary.TotalConsumption - currentSummary.TotalGridImport, 1);
var totalSavingsCHF = Math.Round(totalEnergySaved * ElectricityPriceCHF, 0);
var avgDailyConsumption = currentWeekDays.Count > 0 ? currentSummary.TotalConsumption / currentWeekDays.Count : 0;
var daysEquivalent = avgDailyConsumption > 0 ? Math.Round(totalEnergySaved / avgDailyConsumption, 1) : 0;
// AI insight combining daily facts + behavioral pattern
var aiInsight = await GetAiInsightAsync(
currentWeekDays, currentSummary, previousSummary,
selfSufficiency, totalEnergySaved, totalSavingsCHF,
behavior, installationName, language);
return new WeeklyReportResponse
{
InstallationName = installationName,
PeriodStart = currentWeekDays.First().Date,
PeriodEnd = currentWeekDays.Last().Date,
CurrentWeek = currentSummary,
PreviousWeek = previousSummary,
TotalEnergySaved = totalEnergySaved,
TotalSavingsCHF = totalSavingsCHF,
DaysEquivalent = daysEquivalent,
SelfSufficiencyPercent = selfSufficiency,
SelfConsumptionPercent = selfConsumption,
BatteryEfficiencyPercent = batteryEfficiency,
GridDependencyPercent = gridDependency,
PvChangePercent = pvChange,
ConsumptionChangePercent = consumptionChange,
GridImportChangePercent = gridImportChange,
DailyData = allDays,
Behavior = behavior,
AiInsight = aiInsight,
};
}
private static WeeklySummary Summarize(List<DailyEnergyData> days) => new()
{
TotalPvProduction = Math.Round(days.Sum(d => d.PvProduction), 1),
TotalConsumption = Math.Round(days.Sum(d => d.LoadConsumption), 1),
TotalGridImport = Math.Round(days.Sum(d => d.GridImport), 1),
TotalGridExport = Math.Round(days.Sum(d => d.GridExport), 1),
TotalBatteryCharged = Math.Round(days.Sum(d => d.BatteryCharged), 1),
TotalBatteryDischarged = Math.Round(days.Sum(d => d.BatteryDischarged), 1),
};
private static double PercentChange(double? previous, double current)
{
if (previous is null or 0) return 0;
return Math.Round((current - previous.Value) / previous.Value * 100, 1);
}
// ── Mistral AI Insight ──────────────────────────────────────────────
private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions";
private static string LanguageName(string code) => code switch
{
"de" => "German",
"fr" => "French",
"it" => "Italian",
_ => "English"
};
private static string FormatHour(int hour) => $"{hour:D2}:00";
private static string FormatHourSlot(int hour) => $"{hour:D2}:00{hour + 1:D2}:00";
private static async Task<string> GetAiInsightAsync(
List<DailyEnergyData> currentWeek,
WeeklySummary current,
WeeklySummary? previous,
double selfSufficiency,
double totalEnergySaved,
double totalSavingsCHF,
BehavioralPattern behavior,
string installationName,
string language = "en")
{
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.WriteLine("[WeeklyReportService] MISTRAL_API_KEY not set — skipping AI insight.");
return "AI insight unavailable (API key not configured).";
}
const double ElectricityPriceCHF = 0.39;
// Detect which components are present
var hasPv = currentWeek.Sum(d => d.PvProduction) > 0.5;
var hasBattery = currentWeek.Sum(d => d.BatteryCharged) > 0.5
|| currentWeek.Sum(d => d.BatteryDischarged) > 0.5;
var hasGrid = currentWeek.Sum(d => d.GridImport) > 0.5;
var bestDay = currentWeek.OrderByDescending(d => d.PvProduction).First();
var worstDay = currentWeek.OrderBy(d => d.PvProduction).First();
var bestDayName = DateTime.Parse(bestDay.Date).ToString("dddd");
var worstDayName = DateTime.Parse(worstDay.Date).ToString("dddd");
var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First();
var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd");
// Behavioral facts as compact lines
var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "" + FormatHour(behavior.PeakSolarEndHour);
var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0);
var battDepleteLine = hasBattery
? (behavior.AvgBatteryDepletedHour >= 0
? $"Battery typically depletes below 20% during {FormatHourSlot(behavior.AvgBatteryDepletedHour)}."
: "Battery stayed above 20% SoC every night this week.")
: "";
var weekdayWeekendLine = behavior.WeekendAvgDailyLoad > 0
? $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day. Weekend avg: {behavior.WeekendAvgDailyLoad} kWh/day."
: $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day.";
// Build conditional fact lines
var pvDailyFact = hasPv
? $"- PV: total {current.TotalPvProduction:F1} kWh this week. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh), worst: {worstDayName} ({worstDay.PvProduction:F1} kWh). Solar covered {selfSufficiency}% of consumption."
: "";
var battDailyFact = hasBattery
? $"- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged)."
: "";
var gridDailyFact = hasGrid
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
: "";
var pvBehaviorLines = hasPv ? $@"
- Solar active window: {peakSolarWindow}; peak hour: {FormatHourSlot(behavior.PeakSolarHour)}, avg {behavior.AvgPeakSolarKwh} kWh during that hour
- Grid imported while solar was active: {behavior.AvoidableGridKwh} kWh = {avoidableSavingsCHF} CHF that could have been avoided" : "";
var gridBehaviorLine = hasGrid
? $"- Highest grid-import hour: {FormatHourSlot(behavior.HighestGridImportHour)}, avg {behavior.AvgGridImportAtPeakHour} kWh during that hour"
: "";
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
// Build conditional instructions
var instruction1 = $"1. Energy savings: Write 12 sentences. Say that this week, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
var instruction2 = hasPv
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason."
: hasGrid
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week and what time of day drives it most ({FormatHourSlot(behavior.HighestGridImportHour)})."
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
var instruction3 = hasBattery
? $"3. Battery performance: Use the daily facts. Keep it simple for a homeowner."
: "3. Consumption pattern: Comment on the peak load time and weekday vs weekend usage.";
var instruction4 = hasPv
? $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has."
: hasGrid
? $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time."
: "4. Smart action for next week: Give one practical tip to reduce energy consumption based on the peak load time and weekday/weekend pattern.";
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Write 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown plain text only.
IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure.
CRITICAL: All numbers below are pre-calculated. Use these values as-is do not recalculate, round differently, or change any number.
SYSTEM COMPONENTS: PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}
DAILY FACTS:
- Total consumption: {current.TotalConsumption:F1} kWh this week. Self-sufficiency: {selfSufficiency}%.
{pvDailyFact}
{battDailyFact}
{gridDailyFact}
BEHAVIORAL PATTERN (from hourly data this week):
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
- {weekdayWeekendLine}{pvBehaviorLines}
{gridBehaviorLine}
{battBehaviorLine}
INSTRUCTIONS:
{instruction1}
{instruction2}
{instruction3}
{instruction4}
Rules: Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. Only describe components that exist (PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}). Do NOT add any closing remark, summary sentence, or motivational phrase after the 4 bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly 4 bullet points nothing before, nothing after.
IMPORTANT: Write your entire response in {LanguageName(language)}.";
try
{
var requestBody = new
{
model = "mistral-small-latest",
messages = new[] { new { role = "user", content = prompt } },
max_tokens = 400,
temperature = 0.3
};
var responseText = await MistralUrl
.WithHeader("Authorization", $"Bearer {apiKey}")
.PostJsonAsync(requestBody)
.ReceiveString();
var envelope = JsonConvert.DeserializeObject<dynamic>(responseText);
var content = (string?)envelope?.choices?[0]?.message?.content;
if (!string.IsNullOrWhiteSpace(content))
{
var insight = content.Trim();
Console.WriteLine($"[WeeklyReportService] AI insight generated ({insight.Length} chars).");
return insight;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeeklyReportService] Mistral error: {ex.Message}");
}
return "AI insight could not be generated at this time.";
}
}

View File

@ -181,7 +181,7 @@ public static class RabbitMqManager
//If the status has changed, update all the connected front-ends regarding this installation
if(prevStatus != receivedStatusMessage.Status && WebsocketManager.InstallationConnections[installationId].Connections.Count > 0)
{
WebsocketManager.InformWebsocketsForInstallation(installationId);
_ = WebsocketManager.InformWebsocketsForInstallation(installationId); // fire-and-forget: sync event handler, can't await
}
}
}

View File

@ -17,6 +17,8 @@ public static class WebsocketManager
{
while (true)
{
var idsToInform = new List<Int64>();
lock (InstallationConnections)
{
Console.WriteLine("Monitoring installation table...");
@ -31,10 +33,8 @@ public static class WebsocketManager
(installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2))
)
{
Console.WriteLine("Installation ID is " + installationConnection.Key);
Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp);
// Console.WriteLine("diff is "+(DateTime.Now-installationConnection.Value.Timestamp));
installationConnection.Value.Status = (int)StatusType.Offline;
Installation installation = Db.Installations.FirstOrDefault(f => f.Product == installationConnection.Value.Product && f.Id == installationConnection.Key);
@ -42,42 +42,59 @@ public static class WebsocketManager
installation.Apply(Db.Update);
if (installationConnection.Value.Connections.Count > 0)
{
InformWebsocketsForInstallation(installationConnection.Key);
idsToInform.Add(installationConnection.Key);
}
}
}
}
// Send notifications outside the lock so we can await the async SendAsync calls
foreach (var id in idsToInform)
await InformWebsocketsForInstallation(id);
await Task.Delay(TimeSpan.FromMinutes(1));
}
}
//Inform all the connected websockets regarding installation "installationId"
public static void InformWebsocketsForInstallation(Int64 installationId)
public static async Task InformWebsocketsForInstallation(Int64 installationId)
{
var installation = Db.GetInstallationById(installationId);
var installationConnection = InstallationConnections[installationId];
Console.WriteLine("Update all the connected websockets for installation " + installation.Name);
byte[] dataToSend;
List<WebSocket> connections;
var jsonObject = new
lock (InstallationConnections)
{
id = installationId,
status = installationConnection.Status,
testingMode = installation.TestingMode
};
var installationConnection = InstallationConnections[installationId];
Console.WriteLine("Update all the connected websockets for installation " + installation.Name);
string jsonString = JsonSerializer.Serialize(jsonObject);
byte[] dataToSend = Encoding.UTF8.GetBytes(jsonString);
var jsonObject = new
{
id = installationId,
status = installationConnection.Status,
testingMode = installation.TestingMode
};
foreach (var connection in installationConnection.Connections)
{
connection.SendAsync(
new ArraySegment<byte>(dataToSend, 0, dataToSend.Length),
WebSocketMessageType.Text,
true, // Indicates that this is the end of the message
CancellationToken.None
);
dataToSend = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(jsonObject));
connections = installationConnection.Connections.ToList(); // snapshot before releasing lock
}
// Send to all connections concurrently (preserves original fire-and-forget intent),
// but isolate failures so one closed socket doesn't affect others or crash the caller.
await Task.WhenAll(connections
.Where(c => c.State == WebSocketState.Open)
.Select(async c =>
{
try
{
await c.SendAsync(new ArraySegment<byte>(dataToSend, 0, dataToSend.Length),
WebSocketMessageType.Text, true, CancellationToken.None);
}
catch (Exception ex)
{
Console.WriteLine($"WebSocket send failed for installation {installationId}: {ex.Message}");
}
}));
}
@ -109,7 +126,7 @@ public static class WebsocketManager
var jsonString = JsonSerializer.Serialize(jsonObject);
var dataToSend = Encoding.UTF8.GetBytes(jsonString);
currentWebSocket.SendAsync(dataToSend,
await currentWebSocket.SendAsync(dataToSend,
WebSocketMessageType.Text,
true,
CancellationToken.None
@ -120,6 +137,7 @@ public static class WebsocketManager
//Received a new message from this websocket.
//We have a HandleWebSocketConnection per connected frontend
byte[] encodedDataToSend;
lock (InstallationConnections)
{
List<WebsocketMessage> dataToSend = new List<WebsocketMessage>();
@ -157,15 +175,7 @@ public static class WebsocketManager
}
var jsonString = JsonSerializer.Serialize(dataToSend);
var encodedDataToSend = Encoding.UTF8.GetBytes(jsonString);
currentWebSocket.SendAsync(encodedDataToSend,
WebSocketMessageType.Text,
true, // Indicates that this is the end of the message
CancellationToken.None
);
encodedDataToSend = Encoding.UTF8.GetBytes(jsonString);
// Console.WriteLine("Printing installation connection list");
// Console.WriteLine("----------------------------------------------");
@ -175,6 +185,12 @@ public static class WebsocketManager
// }
// Console.WriteLine("----------------------------------------------");
}
await currentWebSocket.SendAsync(encodedDataToSend,
WebSocketMessageType.Text,
true,
CancellationToken.None
);
}
lock (InstallationConnections)

View File

@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
generate_alarm_translations.py
Post-campaign script: reads AlarmTranslationsChecked.de.json (the reviewed and
AI-synthesized German content), translates into English, French, and Italian,
and writes:
Resources/AlarmTranslations.de.json replace with reviewed German
Resources/AlarmTranslations.en.json back-translated from German
Resources/AlarmTranslations.fr.json translated from German
Resources/AlarmTranslations.it.json translated from German
Services/AlarmKnowledgeBase.cs updated English source (keeps same structure)
Run this AFTER the review campaign is complete:
export MISTRAL_API_KEY=your_key_here
cd csharp/App/Backend
python3 generate_alarm_translations.py
"""
import json
import os
import re
import sys
import time
import shutil
from typing import Optional
import requests
# ── Config ─────────────────────────────────────────────────────────────────
CHECKED_FILE = "Resources/AlarmTranslationsChecked.de.json"
KNOWLEDGE_BASE = "Services/AlarmKnowledgeBase.cs"
RESOURCES_DIR = "Resources"
MISTRAL_URL = "https://api.mistral.ai/v1/chat/completions"
MISTRAL_MODEL = "mistral-large-latest"
BATCH_SIZE = 5 # alarms per API call
RETRY_DELAY = 5 # seconds between retries on rate-limit
MAX_RETRIES = 3
REQUEST_TIMEOUT = (10, 90)
TARGET_LANGUAGES = {
"en": "English",
"fr": "French",
"it": "Italian",
}
# ── Mistral API ─────────────────────────────────────────────────────────────
def call_mistral(api_key: str, prompt: str) -> Optional[str]:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
body = {
"model": MISTRAL_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1800,
"temperature": 0.1,
}
for attempt in range(1, MAX_RETRIES + 1):
try:
resp = requests.post(MISTRAL_URL, headers=headers, json=body, timeout=REQUEST_TIMEOUT)
if resp.status_code == 429:
print(f" Rate limited, waiting {RETRY_DELAY}s (attempt {attempt}/{MAX_RETRIES})...")
time.sleep(RETRY_DELAY * attempt)
continue
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"].strip()
if content.startswith("```"):
first_newline = content.index("\n")
content = content[first_newline + 1:]
if content.endswith("```"):
content = content[:-3].strip()
return content
except requests.RequestException as e:
print(f" HTTP error: {e} (attempt {attempt}/{MAX_RETRIES})")
time.sleep(RETRY_DELAY)
return None
def translate_batch(api_key: str, batch: dict, target_language: str) -> Optional[dict]:
"""
Translates a batch of German alarm entries into the target language.
Input: { "AlarmKey": { "Explanation": "...", "Causes": [...], "NextSteps": [...] } }
Output: same structure in target language.
"""
prompt = f"""You are translating battery energy storage system alarm descriptions from German into {target_language}.
The source content has been reviewed by field engineers and is accurate.
Translate faithfully keep the same number of bullet points, same meaning, plain language for homeowners.
Input JSON (German):
{json.dumps(batch, ensure_ascii=False, indent=2)}
Return ONLY a valid JSON object with the same alarm keys. Each value must have exactly:
{{
"Explanation": "translated explanation (1 sentence)",
"Causes": ["translated cause 1", ...],
"NextSteps": ["translated step 1", ...]
}}
Reply with ONLY the JSON object, no markdown, no extra text."""
raw = call_mistral(api_key, prompt)
if raw is None:
return None
try:
return json.loads(raw)
except json.JSONDecodeError as e:
print(f" JSON parse error: {e}")
print(f" Raw (first 300 chars): {raw[:300]}")
return None
# ── AlarmKnowledgeBase.cs generation ────────────────────────────────────────
def parse_kb_key_sections(filepath: str) -> dict:
"""
Reads AlarmKnowledgeBase.cs and returns {key: "Sinexcel"|"Growatt"}
preserving the original section order.
"""
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
sinexcel_match = re.search(r'SinexcelAlarms\s*=\s*new Dictionary.*?\{(.*?)^\s*\};', content, re.DOTALL | re.MULTILINE)
growatt_match = re.search(r'GrowattAlarms\s*=\s*new Dictionary.*?\{(.*?)^\s*\};', content, re.DOTALL | re.MULTILINE)
result = {}
if sinexcel_match:
for key in re.findall(r'\["(\w+)"\]\s*=\s*new\(\)', sinexcel_match.group(1)):
result[key] = "Sinexcel"
if growatt_match:
for key in re.findall(r'\["(\w+)"\]\s*=\s*new\(\)', growatt_match.group(1)):
result[key] = "Growatt"
return result
def cs_escape(s: str) -> str:
"""Escapes a string for use inside a C# double-quoted string literal."""
return s.replace("\\", "\\\\").replace('"', '\\"')
def write_knowledge_base_cs(filepath: str, en_translations: dict, key_sections: dict):
"""
Writes an updated AlarmKnowledgeBase.cs using the new English translations,
preserving the original Sinexcel/Growatt section structure.
"""
sinexcel_keys = [k for k, s in key_sections.items() if s == "Sinexcel"]
growatt_keys = [k for k, s in key_sections.items() if s == "Growatt"]
def entry_block(key: str) -> str:
entry = en_translations.get(key)
if not entry:
return f' // [{key}] — no translation available\n'
exp = cs_escape(entry.get("Explanation", ""))
causes = ",\n ".join(f'"{cs_escape(c)}"' for c in entry.get("Causes", []))
steps = ",\n ".join(f'"{cs_escape(s)}"' for s in entry.get("NextSteps", []))
return (
f' ["{key}"] = new()\n'
f' {{\n'
f' Explanation = "{exp}",\n'
f' Causes = new[] {{ {causes} }},\n'
f' NextSteps = new[] {{ {steps} }}\n'
f' }},\n'
)
lines = []
lines.append("namespace InnovEnergy.App.Backend.Services;\n")
lines.append("\n")
lines.append("/// <summary>\n")
lines.append("/// Static knowledge base for Sinexcel and Growatt alarms.\n")
lines.append("/// Provides pre-defined diagnostics without requiring Mistral API calls.\n")
lines.append("/// Updated by generate_alarm_translations.py after the review campaign.\n")
lines.append("/// </summary>\n")
lines.append("public static class AlarmKnowledgeBase\n")
lines.append("{\n")
lines.append(" public static DiagnosticResponse? TryGetDiagnosis(string alarmDescription)\n")
lines.append(" {\n")
lines.append(" if (string.IsNullOrWhiteSpace(alarmDescription)) return null;\n")
lines.append(" var normalized = alarmDescription.Trim();\n")
lines.append(" if (SinexcelAlarms.TryGetValue(normalized, out var s)) return s;\n")
lines.append(" if (GrowattAlarms.TryGetValue(normalized, out var g)) return g;\n")
lines.append(" var lower = normalized.ToLowerInvariant();\n")
lines.append(" foreach (var kvp in SinexcelAlarms) if (kvp.Key.ToLowerInvariant() == lower) return kvp.Value;\n")
lines.append(" foreach (var kvp in GrowattAlarms) if (kvp.Key.ToLowerInvariant() == lower) return kvp.Value;\n")
lines.append(" return null;\n")
lines.append(" }\n")
lines.append("\n")
lines.append(" // ── Sinexcel Alarms ──────────────────────────────────────────────────────\n")
lines.append("\n")
lines.append(" private static readonly IReadOnlyDictionary<string, DiagnosticResponse> SinexcelAlarms = new Dictionary<string, DiagnosticResponse>\n")
lines.append(" {\n")
for key in sinexcel_keys:
lines.append(entry_block(key))
lines.append(" };\n")
lines.append("\n")
lines.append(" // ── Growatt Alarms ───────────────────────────────────────────────────────\n")
lines.append("\n")
lines.append(" private static readonly IReadOnlyDictionary<string, DiagnosticResponse> GrowattAlarms = new Dictionary<string, DiagnosticResponse>\n")
lines.append(" {\n")
for key in growatt_keys:
lines.append(entry_block(key))
lines.append(" };\n")
lines.append("}\n")
with open(filepath, "w", encoding="utf-8") as f:
f.writelines(lines)
print(f" ✓ Wrote updated AlarmKnowledgeBase.cs ({len(sinexcel_keys)} Sinexcel + {len(growatt_keys)} Growatt keys)")
# ── Main ────────────────────────────────────────────────────────────────────
def load_env_file(env_path: str) -> dict:
env = {}
try:
with open(env_path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, _, v = line.partition("=")
env[k.strip()] = v.strip()
except FileNotFoundError:
pass
return env
def main():
api_key = os.environ.get("MISTRAL_API_KEY", "").strip()
if not api_key:
script_dir = os.path.dirname(os.path.abspath(__file__))
api_key = load_env_file(os.path.join(script_dir, ".env")).get("MISTRAL_API_KEY", "").strip()
if not api_key:
print("ERROR: MISTRAL_API_KEY not found in environment or .env file.")
sys.exit(1)
print("MISTRAL_API_KEY loaded.")
# Load reviewed German source
if not os.path.exists(CHECKED_FILE):
print(f"ERROR: {CHECKED_FILE} not found. Run the review campaign first.")
sys.exit(1)
with open(CHECKED_FILE, "r", encoding="utf-8") as f:
german_source = json.load(f)
alarm_keys = list(german_source.keys())
print(f"Loaded {len(alarm_keys)} alarms from {CHECKED_FILE}.")
# Step 1: copy reviewed German as the new de.json
de_out = os.path.join(RESOURCES_DIR, "AlarmTranslations.de.json")
shutil.copy(CHECKED_FILE, de_out)
print(f"\n✓ Copied reviewed German → {de_out}")
# Step 2: translate to en, fr, it
all_translations = {} # lang_code → {key → entry}
for lang_code, lang_name in TARGET_LANGUAGES.items():
print(f"\n── Translating to {lang_name} ({lang_code}) ──")
translations = {}
failed_keys = []
batches = [
{k: german_source[k] for k in alarm_keys[i:i + BATCH_SIZE]}
for i in range(0, len(alarm_keys), BATCH_SIZE)
]
for batch_num, batch in enumerate(batches, 1):
keys_in_batch = list(batch.keys())
print(f" Batch {batch_num}/{len(batches)}: {', '.join(keys_in_batch)}")
result = translate_batch(api_key, batch, lang_name)
if result is None:
print(f" FAILED batch {batch_num} — marking keys as failed")
failed_keys.extend(keys_in_batch)
continue
for key in keys_in_batch:
if key in result:
entry = result[key]
translations[key] = {
"Explanation": entry.get("Explanation", ""),
"Causes": entry.get("Causes", []),
"NextSteps": entry.get("NextSteps", []),
}
else:
print(f" WARNING: key '{key}' missing from batch result")
failed_keys.append(key)
if batch_num < len(batches):
time.sleep(1)
all_translations[lang_code] = translations
out_file = os.path.join(RESOURCES_DIR, f"AlarmTranslations.{lang_code}.json")
with open(out_file, "w", encoding="utf-8") as f:
json.dump(translations, f, ensure_ascii=False, indent=2)
print(f" ✓ Wrote {len(translations)} entries → {out_file}")
if failed_keys:
print(f" ⚠ Failed keys ({len(failed_keys)}): {failed_keys}")
# Step 3: update AlarmKnowledgeBase.cs with the new English back-translation
print("\n── Updating AlarmKnowledgeBase.cs ──")
if "en" in all_translations and os.path.exists(KNOWLEDGE_BASE):
key_sections = parse_kb_key_sections(KNOWLEDGE_BASE)
write_knowledge_base_cs(KNOWLEDGE_BASE, all_translations["en"], key_sections)
else:
print(" Skipped — en.json not generated or AlarmKnowledgeBase.cs not found.")
print("\n✓ Done. Review the output files before deploying.")
print(" Next: cd csharp/App/Backend && dotnet build && ./deploy.sh")
if __name__ == "__main__":
main()

Binary file not shown.

View File

@ -1,21 +1,22 @@
import { Navigate, Route, Routes, useNavigate } from 'react-router-dom';
import { CssBaseline } from '@mui/material';
import ThemeProvider from './theme/ThemeProvider';
import React, { lazy, Suspense, useContext, useState } from 'react';
import React, { lazy, Suspense, useContext, useEffect, useState } from 'react';
import { UserContext } from './contexts/userContext';
import Login from './components/login';
import { IntlProvider } from 'react-intl';
import en from './lang/en.json';
import de from './lang/de.json';
import fr from './lang/fr.json';
import it from './lang/it.json';
import SuspenseLoader from './components/SuspenseLoader';
import axiosConfig, { axiosConfigWithoutToken } from './Resources/axiosConfig';
import SidebarLayout from './layouts/SidebarLayout';
import { TokenContext } from './contexts/tokenContext';
import InstallationTabs from './content/dashboards/Installations/index';
import routes from 'src/Resources/routes.json';
import './App.css';
import ForgotPassword from './components/ForgotPassword';
import { axiosConfigWithoutToken } from './Resources/axiosConfig';
import InstallationsContextProvider from './contexts/InstallationsContextProvider';
import AccessContextProvider from './contexts/AccessContextProvider';
import SalidomoInstallationTabs from './content/dashboards/SalidomoInstallations';
@ -37,15 +38,38 @@ function App() {
setAccessToSodistore
} = useContext(ProductIdContext);
const [language, setLanguage] = useState('en');
const [language, setLanguage] = useState<string>(
() => localStorage.getItem('language') || currentUser?.language || 'en'
);
const onSelectLanguage = (lang: string) => {
setLanguage(lang);
localStorage.setItem('language', lang);
if (currentUser) {
const updatedUser = { ...currentUser, language: lang };
setUser(updatedUser);
axiosConfig.put('/UpdateUser', updatedUser).catch(() => {});
}
};
// Sync localStorage language to DB when it differs (e.g. user changed language before new code was deployed)
useEffect(() => {
if (currentUser && token) {
const storedLang = localStorage.getItem('language');
if (storedLang && storedLang !== currentUser.language) {
const updatedUser = { ...currentUser, language: storedLang };
setUser(updatedUser);
axiosConfig.put('/UpdateUser', updatedUser).catch(() => {});
}
}
}, [token]);
const getTranslations = () => {
switch (language) {
case 'en':
return en;
case 'de':
return de;
case 'fr':
return fr;
case 'de': return de;
case 'fr': return fr;
case 'it': return it;
default: return en;
}
};
@ -151,7 +175,7 @@ function App() {
element={
<SidebarLayout
language={language}
onSelectLanguage={setLanguage}
onSelectLanguage={onSelectLanguage}
/>
}
>

View File

@ -20,5 +20,6 @@
"configuration": "configuration",
"history": "history",
"mainstats": "mainstats",
"detailed_view": "detailed_view/"
"detailed_view": "detailed_view/",
"report": "report"
}

View File

@ -1,4 +1,5 @@
import React, { useContext, useEffect, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { I_S3Credentials } from '../../../interfaces/S3Types';
import {
Box,
@ -36,6 +37,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
if (props.batteryData === null) {
return null;
}
const intl = useIntl();
const navigate = useNavigate();
const [openModalFirmwareUpdate, setOpenModalFirmwareUpdate] = useState(false);
const [openModalResultFirmwareUpdate, setOpenModalResultFirmwareUpdate] =
@ -242,7 +244,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}
} catch (error) {
console.error('Error:', error.message);
setErrorMessage('Download battery log failed, please try again.');
setErrorMessage(intl.formatMessage({ id: 'downloadBatteryLogFailed' }));
setOpenModalError(true);
} finally {
setOpenModalStartDownloadBatteryLog(false);
@ -282,7 +284,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
The firmware is getting updated. Please wait...
<FormattedMessage id="firmwareUpdating" defaultMessage="The firmware is getting updated. Please wait..." />
</Typography>
<div
@ -302,7 +304,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={firmwareModalResultHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>
@ -337,12 +339,11 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
Do you really want to update the firmware?
<FormattedMessage id="confirmFirmwareUpdate" defaultMessage="Do you really want to update the firmware?" />
</Typography>
<Typography variant="body1" gutterBottom>
This action requires the battery service to be stopped for around
10-15 minutes.
<FormattedMessage id="batteryServiceStopWarning" defaultMessage="This action requires the battery service to be stopped for around 10-15 minutes." />
</Typography>
<div
@ -362,7 +363,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={FirmwareModalHandleProceed}
>
Proceed
<FormattedMessage id="proceed" defaultMessage="Proceed" />
</Button>
<Button
sx={{
@ -375,7 +376,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={FirmwareModalHandleCancel}
>
Cancel
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
@ -409,8 +410,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
The battery log is getting downloaded. It will be saved in the
Downloads folder. Please wait...
<FormattedMessage id="downloadingBatteryLog" defaultMessage="The battery log is getting downloaded. It will be saved in the Downloads folder. Please wait..." />
</Typography>
<div
@ -430,7 +430,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={StartDownloadBatteryLogModalHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>
@ -465,12 +465,11 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
Do you really want to download battery log?
<FormattedMessage id="confirmBatteryLogDownload" defaultMessage="Do you really want to download battery log?" />
</Typography>
<Typography variant="body1" gutterBottom>
This action requires the battery service to be stopped for around
10-15 minutes.
<FormattedMessage id="batteryServiceStopWarning" defaultMessage="This action requires the battery service to be stopped for around 10-15 minutes." />
</Typography>
<div
@ -490,7 +489,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={DownloadBatteryLogModalHandleProceed}
>
Proceed
<FormattedMessage id="proceed" defaultMessage="Proceed" />
</Button>
<Button
sx={{
@ -503,7 +502,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={DownloadBatteryLogModalHandleCancel}
>
Cancel
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
@ -553,7 +552,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) {
}}
onClick={ErrorModalHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>

View File

@ -1,4 +1,5 @@
import React, { useContext, useEffect, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { I_S3Credentials } from '../../../interfaces/S3Types';
import {
Box,
@ -36,7 +37,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
if (props.batteryData === null) {
return null;
}
const intl = useIntl();
const navigate = useNavigate();
const [openModalFirmwareUpdate, setOpenModalFirmwareUpdate] = useState(false);
const [openModalResultFirmwareUpdate, setOpenModalResultFirmwareUpdate] =
@ -243,7 +244,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}
} catch (error) {
console.error('Error:', error.message);
setErrorMessage('Download battery log failed, please try again.');
setErrorMessage(intl.formatMessage({ id: 'downloadBatteryLogFailed' }));
setOpenModalError(true);
} finally {
setOpenModalStartDownloadBatteryLog(false);
@ -283,7 +284,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
The firmware is getting updated. Please wait...
<FormattedMessage id="firmwareUpdating" defaultMessage="The firmware is getting updated. Please wait..." />
</Typography>
<div
@ -303,7 +304,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={firmwareModalResultHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>
@ -338,12 +339,11 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
Do you really want to update the firmware?
<FormattedMessage id="confirmFirmwareUpdate" defaultMessage="Do you really want to update the firmware?" />
</Typography>
<Typography variant="body1" gutterBottom>
This action requires the battery service to be stopped for around
10-15 minutes.
<FormattedMessage id="batteryServiceStopWarning" defaultMessage="This action requires the battery service to be stopped for around 10-15 minutes." />
</Typography>
<div
@ -363,7 +363,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={FirmwareModalHandleProceed}
>
Proceed
<FormattedMessage id="proceed" defaultMessage="Proceed" />
</Button>
<Button
sx={{
@ -376,7 +376,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={FirmwareModalHandleCancel}
>
Cancel
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
@ -410,8 +410,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
The battery log is getting downloaded. It will be saved in the
Downloads folder. Please wait...
<FormattedMessage id="downloadingBatteryLog" defaultMessage="The battery log is getting downloaded. It will be saved in the Downloads folder. Please wait..." />
</Typography>
<div
@ -431,7 +430,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={StartDownloadBatteryLogModalHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>
@ -466,12 +465,11 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
Do you really want to download battery log?
<FormattedMessage id="confirmBatteryLogDownload" defaultMessage="Do you really want to download battery log?" />
</Typography>
<Typography variant="body1" gutterBottom>
This action requires the battery service to be stopped for around
10-15 minutes.
<FormattedMessage id="batteryServiceStopWarning" defaultMessage="This action requires the battery service to be stopped for around 10-15 minutes." />
</Typography>
<div
@ -491,7 +489,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={DownloadBatteryLogModalHandleProceed}
>
Proceed
<FormattedMessage id="proceed" defaultMessage="Proceed" />
</Button>
<Button
sx={{
@ -504,7 +502,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={DownloadBatteryLogModalHandleCancel}
>
Cancel
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
@ -554,7 +552,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) {
}}
onClick={ErrorModalHandleOk}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</div>
</Box>

View File

@ -175,6 +175,9 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
return true;
};
const canEdit = currentUser.userType === UserType.admin;
const isPartner = currentUser.userType === UserType.partner;
return (
<>
{openModalDeleteInstallation && (
@ -276,6 +279,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
@ -288,8 +292,9 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
onChange={handleChange}
variant="outlined"
fullWidth
required
error={formValues.region === ''}
required={canEdit}
error={canEdit && formValues.region === ''}
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
@ -305,8 +310,9 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
onChange={handleChange}
variant="outlined"
fullWidth
required
error={formValues.location === ''}
required={canEdit}
error={canEdit && formValues.location === ''}
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
@ -319,23 +325,26 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
onChange={handleChange}
variant="outlined"
fullWidth
required
error={formValues.country === ''}
required={canEdit}
error={canEdit && formValues.country === ''}
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
<TextField
label={
<FormattedMessage id="vpnip" defaultMessage="VPN IP" />
}
name="vpnIp"
value={formValues.vpnIp}
onChange={handleChange}
variant="outlined"
fullWidth
/>
</div>
{canEdit && (
<div>
<TextField
label={
<FormattedMessage id="vpnip" defaultMessage="VPN IP" />
}
name="vpnIp"
value={formValues.vpnIp}
onChange={handleChange}
variant="outlined"
fullWidth
/>
</div>
)}
<div>
<FormControl
@ -362,6 +371,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
name="device"
value={formValues.device}
onChange={handleChange}
inputProps={{ readOnly: !canEdit }}
>
{DeviceTypes.map((device) => (
<MenuItem key={device.id} value={device.id}>
@ -372,106 +382,116 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</FormControl>
</div>
<div>
<TextField
label={
<FormattedMessage
id="installationSerialNumber"
defaultMessage="Installation Serial Number"
/>
}
name="serialNumber"
value={formValues.serialNumber}
onChange={handleChange}
variant="outlined"
fullWidth
/>
</div>
<div>
<TextField
label={
<FormattedMessage
id="inverterSN"
defaultMessage="Inverter Serial Number"
/>
}
name="inverterSN"
value={formValues.inverterSN}
onChange={handleChange}
variant="outlined"
fullWidth
/>
</div>
<div>
<TextField
label={
<FormattedMessage
id="dataloggerSN"
defaultMessage="Datalogger Serial Number"
/>
}
name="dataloggerSN"
value={formValues.dataloggerSN}
onChange={handleChange}
variant="outlined"
fullWidth
/>
</div>
<div>
<TextField
label={
<FormattedMessage
id="batteryClusterNumber"
defaultMessage="Battery Cluster Number"
/>
}
name="batteryClusterNumber"
value={formValues.batteryClusterNumber}
onChange={handleChange}
variant="outlined"
fullWidth
/>
</div>
<div>
<TextField
label={
<FormattedMessage
id="batteryNumber"
defaultMessage="Battery Number"
/>
}
name="batteryNumber"
type="text"
value={batteryNumber === 0 ? '' : batteryNumber}
onChange={handleBatteryNumberChange}
variant="outlined"
fullWidth
placeholder="Enter number of batteries"
/>
</div>
{batteryNumber > 0 &&
batterySerialNumbers.map((serialNumber, index) => (
<div key={index}>
{(canEdit || isPartner) && (
<>
<div>
<TextField
label={`Battery Pack ${index + 1}`}
name={`batterySN${index + 1}`}
value={serialNumber}
onChange={(e) =>
handleBatterySerialNumberChange(index, e.target.value)
label={
<FormattedMessage
id="installationSerialNumber"
defaultMessage="Installation Serial Number"
/>
}
onKeyDown={(e) => handleBatterySnKeyDown(e, index)}
inputRef={(el) => (batterySnRefs.current[index] = el)}
name="serialNumber"
value={formValues.serialNumber}
onChange={handleChange}
variant="outlined"
fullWidth
placeholder="Scan or enter serial number"
inputProps={{ readOnly: !canEdit }}
/>
</div>
))}
<div>
<TextField
label={
<FormattedMessage
id="inverterSN"
defaultMessage="Inverter Serial Number"
/>
}
name="inverterSN"
value={formValues.inverterSN}
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
<TextField
label={
<FormattedMessage
id="dataloggerSN"
defaultMessage="Datalogger Serial Number"
/>
}
name="dataloggerSN"
value={formValues.dataloggerSN}
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
<TextField
label={
<FormattedMessage
id="batteryClusterNumber"
defaultMessage="Battery Cluster Number"
/>
}
name="batteryClusterNumber"
value={formValues.batteryClusterNumber}
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
<TextField
label={
<FormattedMessage
id="batteryNumber"
defaultMessage="Battery Number"
/>
}
name="batteryNumber"
type="text"
value={batteryNumber === 0 ? '' : batteryNumber}
onChange={handleBatteryNumberChange}
variant="outlined"
fullWidth
placeholder={canEdit ? 'Enter number of batteries' : ''}
inputProps={{ readOnly: !canEdit }}
/>
</div>
{batteryNumber > 0 &&
batterySerialNumbers.map((serialNumber, index) => (
<div key={index}>
<TextField
label={`Battery Pack ${index + 1}`}
name={`batterySN${index + 1}`}
value={serialNumber}
onChange={(e) =>
handleBatterySerialNumberChange(index, e.target.value)
}
onKeyDown={(e) => handleBatterySnKeyDown(e, index)}
inputRef={(el) => (batterySnRefs.current[index] = el)}
variant="outlined"
fullWidth
placeholder={canEdit ? 'Scan or enter serial number' : ''}
inputProps={{ readOnly: !canEdit }}
/>
</div>
))}
</>
)}
<div>
<TextField
@ -486,10 +506,11 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
{currentUser.userType == UserType.admin && (
{canEdit && (
<>
<div>
<TextField
@ -533,21 +554,23 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
marginTop: 10
}}
>
<Button
variant="contained"
onClick={handleSubmit}
sx={{
marginLeft: '10px'
}}
disabled={!areRequiredFieldsFilled()}
>
<FormattedMessage
id="applyChanges"
defaultMessage="Apply Changes"
/>
</Button>
{canEdit && (
<Button
variant="contained"
onClick={handleSubmit}
sx={{
marginLeft: '10px'
}}
disabled={!areRequiredFieldsFilled()}
>
<FormattedMessage
id="applyChanges"
defaultMessage="Apply Changes"
/>
</Button>
)}
{currentUser.userType == UserType.admin && (
{canEdit && (
<Button
variant="contained"
onClick={handleDelete}

View File

@ -461,6 +461,7 @@ function Installation(props: singleInstallationProps) {
<Log
errorLoadingS3Data={errorLoadingS3Data}
id={props.current_installation.id}
status={props.current_installation.status}
></Log>
}
/>

View File

@ -1,30 +1,47 @@
import React, { useContext, useEffect, useState } from 'react';
import {
Alert,
Box,
Card,
Chip,
CircularProgress,
Container,
Divider,
Grid,
IconButton,
ListSubheader,
MenuItem,
Select,
TextField,
useTheme
} from '@mui/material';
import Typography from '@mui/material/Typography';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import ErrorIcon from '@mui/icons-material/Error';
import WarningIcon from '@mui/icons-material/Warning';
import axiosConfig from '../../../Resources/axiosConfig';
import axiosConfig, { axiosConfigWithoutToken } from '../../../Resources/axiosConfig';
import { AxiosError, AxiosResponse } from 'axios/index';
import routes from '../../../Resources/routes.json';
import { useNavigate } from 'react-router-dom';
import { TokenContext } from '../../../contexts/tokenContext';
import { ErrorMessage } from '../../../interfaces/S3Types';
import { ErrorMessage, DiagnosticResponse } from '../../../interfaces/S3Types';
import Button from '@mui/material/Button';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
interface TestDiagnoseResult {
source: string;
alarm: string;
explanation?: string;
causes?: string[];
nextSteps?: string[];
message?: string;
}
interface LogProps {
errorLoadingS3Data: boolean;
id: number;
status?: number;
}
function Log(props: LogProps) {
@ -44,6 +61,26 @@ function Log(props: LogProps) {
const navigate = useNavigate();
const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext;
const intl = useIntl();
/** "AbnormalGridVoltage" → "Abnormal Grid Voltage" */
const splitCamelCase = (s: string) =>
s.replace(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g, ' ').trim();
/** Returns a translated alarm display name, falling back to camelCase split. */
const alarmDisplayName = (description: string) =>
intl.formatMessage({ id: `alarm_${description}`, defaultMessage: splitCamelCase(description) });
const [diagnoses, setDiagnoses] = useState<{ description: string; lastSeen: string; response: DiagnosticResponse }[]>([]);
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [expandedDiagnoses, setExpandedDiagnoses] = useState<Set<number>>(new Set());
// demo panel state
const [demoPanelOpen, setDemoPanelOpen] = useState(false);
const [demoAlarm, setDemoAlarm] = useState<string>('AbnormalGridVoltage');
const [demoCustom, setDemoCustom] = useState<string>('');
const [demoLoading, setDemoLoading] = useState(false);
const [demoResult, setDemoResult] = useState<TestDiagnoseResult | null>(null);
useEffect(() => {
axiosConfig
@ -71,6 +108,59 @@ function Log(props: LogProps) {
});
}, [updateCount]);
// fetch AI diagnosis for all unique errors/warnings from the last 24 hours
useEffect(() => {
// filter to last 24 hours only
const now = new Date();
const cutoff = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const ignore = new Set(['NoAlarm', '0', '']);
const all = [...errors, ...warnings]
.filter(item => {
if (ignore.has(item.description.trim())) return false;
const itemDate = new Date(item.date + 'T' + item.time);
return itemDate >= cutoff;
})
.sort((a, b) => (b.date + ' ' + b.time).localeCompare(a.date + ' ' + a.time));
// deduplicate — keep the most-recent occurrence of each unique description
const seen = new Set<string>();
const targets: ErrorMessage[] = [];
for (const item of all) {
if (!seen.has(item.description)) {
seen.add(item.description);
targets.push(item);
}
}
if (targets.length === 0) {
setDiagnoses([]);
return;
}
// check if the targets changed compared to what we already have
const currentDescs = diagnoses.map(d => d.description).join('|');
const newDescs = targets.map(t => t.description).join('|');
if (currentDescs === newDescs) return;
setDiagnosisLoading(true);
Promise.all(
targets.map(target =>
axiosConfig
.get(`/DiagnoseError?installationId=${props.id}&errorDescription=${encodeURIComponent(target.description)}&language=${intl.locale}`)
.then((res: AxiosResponse<DiagnosticResponse>) => {
if (res.status === 204 || !res.data || !res.data.explanation) return null;
return { description: target.description, lastSeen: target.date + ' ' + target.time, response: res.data };
})
.catch(() => null)
)
).then(results => {
setDiagnoses(results.filter(r => r !== null) as { description: string; lastSeen: string; response: DiagnosticResponse }[]);
}).finally(() => {
setDiagnosisLoading(false);
});
}, [errors, warnings]);
const handleErrorButtonPressed = () => {
setErrorButtonPressed(!errorButtonPressed);
};
@ -107,63 +197,245 @@ function Log(props: LogProps) {
});
};
const warningDescriptionMap: { [key: string]: string } = {
"TaM1": "TaM1: BMS temperature high",
"TbM1": "TbM1: Battery temperature high",
"VBm1": "VBm1: Bus voltage low",
"VBM1": "VBM1: Bus voltage high",
"IDM1": "IDM1: Discharge current high",
"vsm1": "vsm1: String voltage low",
"vsM1": "vsM1: String voltage high",
"iCM1": "iCM1: Charge current high",
"iDM1": "iDM1: Discharge current high",
"MID1": "MID1: String voltages unbalanced",
"BLPW": "BLPW: Not enough charging power on bus",
"CCBF": "CCBF: Internal charger hardware failure",
"Ah_W": "Ah_W: String SOC low",
"MPMM": "MPMM: Midpoint wiring problem",
"TCdi": "TCdi: Temperature difference between strings high",
"LMPW": "LMPW: String voltages unbalance warning",
"TOCW": "TOCW: Top of Charge requested"
const DEMO_ALARMS = {
sinexcel: [
'AbnormalGridVoltage',
'GridVoltagePhaseLoss',
'Battery1NotConnected',
'Battery1Overvoltage',
'LithiumBattery1DischargeForbidden',
'InsulationFault',
'FanFault',
'InverterPowerTubeFault',
'Pv1Overvoltage',
'IslandProtection',
'SystemDerating',
'DcBusOvervoltage',
],
growatt: [
'StringFault',
'PvShortCircuited',
'DcFuseBlown',
'DcInputVoltageTooHigh',
'NoUtilityGrid',
'GridVoltageOutOfRange',
'FanFailure',
'BatteryDisconnected',
'BmsFault',
'BatteryCommunicationFailure',
'BatteryVoltageTooHigh',
'OverTemperatureAlarm',
],
};
const errorDescriptionMap: { [key: string]: string } = {
"Tam": "Tam: Recoverable, BMS temperature too low",
"TaM2": "TaM2: Recoverable, BMS temperature too high",
"Tbm": "Tbm: Recoverable, Battery temperature too low",
"TbM2": "TbM2: Recoverable, Battery temperature too high",
"VBm2": "VBm2: Recoverable, Recoverable: Bus voltage too low",
"VBM2": "VBM2: Recoverable,Recoverable: Bus voltage too high",
"IDM2": "IDM2: Recoverable, Discharge current too high",
"ISOB": "ISOB: Unrecoverable, Electrical insulation failure",
"MSWE": "MSWE: Unrecoverable, Main switch failure",
"FUSE": "FUSE: Unrecoverable, Main fuse blown",
"HTRE": "HTRE: Recoverable, Battery failed to warm up",
"TCPE": "TCPE: Unrecoverable, Temperature sensor failure",
"STRE": "STRE: Recoverable, Voltage measurement circuit fails",
"CME": "CME: Recoverable, Current sensor failure",
"HWFL": "HWFL: Recoverable, BMS hardware failure",
"HWEM": "HWEM: Recoverable, Hardware protection tripped",
"ThM": "ThM: Recoverable, Heatsink temperature too high",
"vsm2": "vsm2: Unrecoverable, Low string voltage failure",
"vsM2": "vsM2: Recoverable, String voltage too high",
"iCM2": "iCM2: Unrecoverable, Charge current too high",
"iDM2": "iDM2: Recoverable, Discharge current too high",
"MID2": "MID2: Recoverable, String voltage unbalance too high",
"HTFS": "HTFS: Recoverable, Unrecoverable: Heater Fuse Blown",
"DATA": "DATA: Recoverable, Unrecoverable: Parameters out of range",
"LMPA": "LMPA: Unrecoverable, String voltages unbalance alarm",
"HEBT": "HEBT: Recoverable, oss of heartbeat"
const runDemo = () => {
const alarm = demoAlarm === '__custom__' ? demoCustom.trim() : demoAlarm;
if (!alarm) return;
setDemoLoading(true);
setDemoResult(null);
axiosConfigWithoutToken
.get(`/TestDiagnoseError?errorDescription=${encodeURIComponent(alarm)}&language=${intl.locale}`)
.then((res: AxiosResponse<TestDiagnoseResult>) => {
setDemoResult(res.data);
})
.catch(() => {
setDemoResult({ source: 'Error', alarm, message: 'Request failed.' });
})
.finally(() => setDemoLoading(false));
};
const sourceChip = (source: string) => {
if (source === 'KnowledgeBase')
return <Chip label="Knowledge Base" size="small" sx={{ bgcolor: '#1976d2', color: '#fff', fontWeight: 'bold' }} />;
if (source === 'MistralAI')
return <Chip label="Mistral AI" size="small" sx={{ bgcolor: '#7b1fa2', color: '#fff', fontWeight: 'bold' }} />;
if (source === 'MistralFailed')
return <Chip label="Mistral failed" size="small" color="error" />;
return <Chip label="Not available" size="small" color="default" />;
};
return (
<Container maxWidth="xl">
<Grid container>
{/* ── AI Diagnosis Demo Panel ── */}
<Grid item xs={12}>
<Box sx={{ marginTop: '20px' }}>
<Button
variant="outlined"
size="small"
onClick={() => { setDemoPanelOpen(!demoPanelOpen); setDemoResult(null); }}
sx={{ textTransform: 'none' }}
>
{demoPanelOpen
? <FormattedMessage id="demo_hide_button" defaultMessage="Hide AI Diagnosis Demo" />
: <FormattedMessage id="demo_test_button" defaultMessage="Test AI Diagnosis" />}
</Button>
</Box>
{demoPanelOpen && (
<Card sx={{ marginTop: '10px', borderLeft: '4px solid #9c27b0', padding: '16px' }}>
<Typography variant="subtitle2" fontWeight="bold" sx={{ mb: 1.5 }}>
<FormattedMessage id="demo_panel_title" defaultMessage="AI Diagnosis Demo" />
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<Select
size="small"
value={demoAlarm}
onChange={e => { setDemoAlarm(e.target.value); setDemoResult(null); }}
sx={{ minWidth: 260 }}
>
<ListSubheader>Sinexcel</ListSubheader>
{DEMO_ALARMS.sinexcel.map(a => (
<MenuItem key={a} value={a}>{a}</MenuItem>
))}
<ListSubheader>Growatt</ListSubheader>
{DEMO_ALARMS.growatt.map(a => (
<MenuItem key={a} value={a}>{a}</MenuItem>
))}
<ListSubheader><FormattedMessage id="demo_custom_group" defaultMessage="Custom (may use Mistral AI)" /></ListSubheader>
<MenuItem value="__custom__"><FormattedMessage id="demo_custom_option" defaultMessage="Type custom alarm below…" /></MenuItem>
</Select>
{demoAlarm === '__custom__' && (
<TextField
size="small"
placeholder={intl.formatMessage({ id: 'demo_custom_placeholder', defaultMessage: 'e.g. UnknownBatteryFault' })}
value={demoCustom}
onChange={e => { setDemoCustom(e.target.value); setDemoResult(null); }}
sx={{ minWidth: 220 }}
/>
)}
<Button
variant="contained"
size="small"
onClick={runDemo}
disabled={demoLoading || (demoAlarm === '__custom__' && !demoCustom.trim())}
sx={{ textTransform: 'none', bgcolor: '#9c27b0', '&:hover': { bgcolor: '#7b1fa2' } }}
>
{demoLoading ? <CircularProgress size={18} sx={{ color: '#fff' }} /> : <FormattedMessage id="demo_diagnose_button" defaultMessage="Diagnose" />}
</Button>
</Box>
{demoResult && (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '8px', mb: 1 }}>
<Typography variant="caption" color="text.secondary">
<strong>{splitCamelCase(demoResult.alarm)}</strong>
</Typography>
{sourceChip(demoResult.source)}
</Box>
{demoResult.message ? (
<Typography variant="body2" color="text.secondary">{demoResult.message}</Typography>
) : (
<>
<Typography variant="body2" sx={{ mt: 0.5 }}>{demoResult.explanation}</Typography>
<Box sx={{ mt: 1 }}>
<Typography variant="caption" fontWeight="bold"><FormattedMessage id="ai_likely_causes" defaultMessage="Likely causes:" /></Typography>
<ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
{(demoResult.causes ?? []).map((c, i) => (
<li key={i}><Typography variant="caption">{c}</Typography></li>
))}
</ul>
<Typography variant="caption" fontWeight="bold"><FormattedMessage id="ai_next_steps" defaultMessage="Suggested next steps:" /></Typography>
<ol style={{ margin: '4px 0', paddingLeft: '20px' }}>
{(demoResult.nextSteps ?? []).map((s, i) => (
<li key={i}><Typography variant="caption">{s}</Typography></li>
))}
</ol>
</Box>
</>
)}
</Box>
)}
</Card>
)}
</Grid>
{/* AI Diagnosis banner — shown when loading or diagnoses are available */}
{diagnosisLoading && (
<Grid item xs={12} md={12}>
<Card sx={{ marginTop: '20px', borderLeft: '4px solid #1976d2' }}>
<Box sx={{ padding: '16px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<CircularProgress size={22} />
<Typography variant="body2" color="text.secondary">
<FormattedMessage id="ai_analyzing" defaultMessage="AI is analyzing..." />
</Typography>
</Box>
</Card>
</Grid>
)}
{!diagnosisLoading && diagnoses.map((diag, idx) => {
const isExpanded = expandedDiagnoses.has(idx);
return (
<Grid item xs={12} md={12} key={idx}>
<Card sx={{ marginTop: idx === 0 ? '20px' : '10px', borderLeft: '4px solid #1976d2' }}>
<Box sx={{ padding: '16px' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '8px', mb: 1 }}>
<Typography variant="subtitle2" fontWeight="bold" color="primary">
{diag.response.name || alarmDisplayName(diag.description)}
</Typography>
<Typography variant="caption" color="text.secondary">
<FormattedMessage id="lastSeen" defaultMessage="Last seen" />: {diag.lastSeen}
</Typography>
</Box>
<Typography variant="body2">
{diag.response.explanation}
</Typography>
<Button
size="small"
onClick={() => {
const next = new Set(expandedDiagnoses);
if (isExpanded) next.delete(idx); else next.add(idx);
setExpandedDiagnoses(next);
}}
sx={{ textTransform: 'none', p: 0, mt: 1 }}
>
<FormattedMessage
id={isExpanded ? 'ai_show_less' : 'ai_show_details'}
defaultMessage={isExpanded ? 'Show less' : 'Show details'}
/>
</Button>
{isExpanded && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" fontWeight="bold">
<FormattedMessage id="ai_likely_causes" defaultMessage="Likely causes:" />
</Typography>
<ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
{diag.response.causes.map((cause, i) => (
<li key={i}><Typography variant="caption">{cause}</Typography></li>
))}
</ul>
<Typography variant="caption" fontWeight="bold">
<FormattedMessage id="ai_next_steps" defaultMessage="Suggested next steps:" />
</Typography>
<ol style={{ margin: '4px 0', paddingLeft: '20px' }}>
{diag.response.nextSteps.map((step, i) => (
<li key={i}><Typography variant="caption">{step}</Typography></li>
))}
</ol>
</Box>
)}
</Box>
</Card>
</Grid>
);
})}
<Grid item xs={12} md={12}>
<Button
variant="contained"
onClick={handleErrorButtonPressed}
sx={{ marginTop: '20px', backgroundColor: errorButtonPressed ? '#808080' : 'default'}}
sx={{ marginTop: '20px', backgroundColor: errorButtonPressed ? '#808080' : 'default', textTransform: 'none' }}
>
<FormattedMessage id="Show Errors" defaultMessage="Show Errors" />
</Button>
@ -353,7 +625,7 @@ function Log(props: LogProps) {
gutterBottom
noWrap
>
{errorDescriptionMap[error.description] || error.description}
{alarmDisplayName(error.description)}
</Typography>
</div>
<div
@ -474,7 +746,7 @@ function Log(props: LogProps) {
<Button
variant="contained"
onClick={handleWarningButtonPressed}
sx={{ marginTop: '20px', backgroundColor: warningButtonPressed ? '#808080' : 'default'}}
sx={{ marginTop: '20px', backgroundColor: warningButtonPressed ? '#808080' : 'default', textTransform: 'none' }}
>
<FormattedMessage
@ -668,7 +940,7 @@ function Log(props: LogProps) {
gutterBottom
noWrap
>
{warningDescriptionMap[warning.description] || warning.description}
{alarmDisplayName(warning.description)}
</Typography>
</div>
<div

View File

@ -26,7 +26,7 @@ import PersonIcon from '@mui/icons-material/Person';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import { AccessContext } from 'src/contexts/AccessContextProvider';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { UserType } from '../../../interfaces/UserTypes';
interface AccessProps {
@ -35,6 +35,7 @@ interface AccessProps {
}
function Access(props: AccessProps) {
const intl = useIntl();
const theme = useTheme();
const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext;
@ -159,31 +160,11 @@ function Access(props: AccessProps) {
if (NotGrantedAccessUsers.length > 0) {
setError(true);
const message =
(
<FormattedMessage
id="unableToGrantAccess"
defaultMessage="Unable to grant access to: "
/>
).props.defaultMessage +
' ' +
NotGrantedAccessUsers.join(', ');
setErrorMessage(message);
setErrorMessage(intl.formatMessage({ id: 'unableToGrantAccess' }) + ' ' + NotGrantedAccessUsers.join(', '));
}
if (grantedAccessUsers.length > 0) {
const message =
(
<FormattedMessage
id="grantedAccessToUsers"
defaultMessage="Granted access to users: "
/>
).props.defaultMessage +
' ' +
grantedAccessUsers.join(', ');
setUpdatedMessage(message);
setUpdatedMessage(intl.formatMessage({ id: 'grantedAccessToUsers' }) + ' ' + grantedAccessUsers.join(', '));
setUpdated(true);
@ -306,7 +287,7 @@ function Access(props: AccessProps) {
}}
onClick={handleCloseFolder}
>
Ok
<FormattedMessage id="ok" defaultMessage="Ok" />
</Button>
</Select>
</FormControl>

View File

@ -15,9 +15,11 @@ import {
IconButton,
InputLabel,
ListItem,
ListSubheader,
MenuItem,
Modal,
Select,
Typography,
useTheme
} from '@mui/material';
import { TokenContext } from 'src/contexts/tokenContext';
@ -26,10 +28,11 @@ import ListItemAvatar from '@mui/material/ListItemAvatar';
import Avatar from '@mui/material/Avatar';
import ListItemText from '@mui/material/ListItemText';
import PersonIcon from '@mui/icons-material/Person';
import FolderIcon from '@mui/icons-material/Folder';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import { AccessContext } from 'src/contexts/AccessContextProvider';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { InnovEnergyUser, UserType } from '../../../interfaces/UserTypes';
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
import {
@ -47,26 +50,29 @@ function UserAccess(props: UserAccessProps) {
return null;
}
const intl = useIntl();
const theme = useTheme();
const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext;
const context = useContext(UserContext);
const { currentUser, setUser } = context;
const { currentUser } = context;
const [openFolder, setOpenFolder] = useState(false);
const [openInstallation, setOpenInstallation] = useState(false);
const [openModal, setOpenModal] = useState(false);
const [selectedFolderNames, setSelectedFolderNames] = useState<string[]>([]);
const [selectedInstallationNames, setSelectedInstallationNames] = useState<
string[]
>([]);
const [selectedInstallationNames, setSelectedInstallationNames] = useState<string[]>([]);
// Available choices for grant modal
const [availableFolders, setAvailableFolders] = useState<I_Folder[]>([]);
const [availableInstallations, setAvailableInstallations] = useState<I_Installation[]>([]);
// Direct grants for this user
const [directFolders, setDirectFolders] = useState<{ id: number; name: string }[]>([]);
const [directInstallations, setDirectInstallations] = useState<{ id: number; name: string }[]>([]);
const [folders, setFolders] = useState<I_Folder[]>([]);
const [installations, setInstallations] = useState<I_Installation[]>([]);
const accessContext = useContext(AccessContext);
const {
fetchInstallationsForUser,
accessibleInstallationsForUser,
error,
setError,
updated,
@ -74,146 +80,118 @@ function UserAccess(props: UserAccessProps) {
updatedmessage,
errormessage,
setErrorMessage,
setUpdatedMessage,
RevokeAccessFromResource
setUpdatedMessage
} = accessContext;
const fetchFolders = useCallback(async () => {
const fetchDirectGrants = useCallback(async () => {
try {
const [foldersRes, installationsRes] = await Promise.all([
axiosConfig.get(`/GetDirectFolderAccessForUser?userId=${props.current_user.id}`),
axiosConfig.get(`/GetDirectInstallationAccessForUser?userId=${props.current_user.id}`)
]);
setDirectFolders(foldersRes.data);
setDirectInstallations(installationsRes.data);
} catch (err) {
if (err.response && err.response.status === 401) removeToken();
}
}, [props.current_user.id]);
const fetchAvailableFolders = useCallback(async () => {
return axiosConfig
.get('/GetAllFolders')
.then((res) => {
setFolders(res.data);
})
.then((res) => setAvailableFolders(res.data))
.catch((err) => {
if (err.response && err.response.status == 401) {
removeToken();
}
if (err.response && err.response.status == 401) removeToken();
});
}, [setFolders]);
}, []);
const fetchInstallations = useCallback(async () => {
const fetchAvailableInstallations = useCallback(async () => {
try {
// fetch product 0
const res0 = await axiosConfig.get(
`/GetAllInstallationsFromProduct?product=0`
);
const installations0 = res0.data;
// fetch product 1
const res1 = await axiosConfig.get(
`/GetAllInstallationsFromProduct?product=3`
);
const installations1 = res1.data;
// aggregate
const combined = [...installations0, ...installations1];
// update
setInstallations(combined);
const [res0, res1, res2, res3] = await Promise.all([
axiosConfig.get(`/GetAllInstallationsFromProduct?product=0`),
axiosConfig.get(`/GetAllInstallationsFromProduct?product=1`),
axiosConfig.get(`/GetAllInstallationsFromProduct?product=2`),
axiosConfig.get(`/GetAllInstallationsFromProduct?product=3`)
]);
setAvailableInstallations([...res0.data, ...res1.data, ...res2.data, ...res3.data]);
} catch (err) {
if (err.response && err.response.status === 401) {
removeToken();
}
} finally {
if (err.response && err.response.status === 401) removeToken();
}
}, [setInstallations]);
}, []);
useEffect(() => {
fetchInstallationsForUser(props.current_user.id);
fetchDirectGrants();
}, [props.current_user]);
const handleGrantAccess = () => {
fetchFolders();
fetchInstallations();
fetchAvailableFolders();
fetchAvailableInstallations();
setOpenModal(true);
setSelectedFolderNames([]);
setSelectedInstallationNames([]);
};
const handleFolderChange = (event) => {
setSelectedFolderNames(event.target.value);
const handleRevokeFolder = async (folderId: number, folderName: string) => {
axiosConfig
.post(`/RevokeUserAccessToFolder?UserId=${props.current_user.id}&FolderId=${folderId}`)
.then(() => {
setUpdatedMessage(intl.formatMessage({ id: 'revokedAccessFromUser' }) + ' ' + props.current_user.name);
setUpdated(true);
setTimeout(() => setUpdated(false), 3000);
fetchDirectGrants();
})
.catch(() => {
setErrorMessage(intl.formatMessage({ id: 'unableToRevokeAccess' }));
setError(true);
});
};
const handleInstallationChange = (event) => {
setSelectedInstallationNames(event.target.value);
};
const handleOpenFolder = () => {
setOpenFolder(true);
};
const handleCloseFolder = () => {
setOpenFolder(false);
};
const handleCancel = () => {
setOpenModal(false);
};
const handleOpenInstallation = () => {
setOpenInstallation(true);
};
const handleCloseInstallation = () => {
setOpenInstallation(false);
const handleRevokeInstallation = async (installationId: number) => {
axiosConfig
.post(`/RevokeUserAccessToInstallation?UserId=${props.current_user.id}&InstallationId=${installationId}`)
.then(() => {
setUpdatedMessage(intl.formatMessage({ id: 'revokedAccessFromUser' }) + ' ' + props.current_user.name);
setUpdated(true);
setTimeout(() => setUpdated(false), 3000);
fetchDirectGrants();
})
.catch(() => {
setErrorMessage(intl.formatMessage({ id: 'unableToRevokeAccess' }));
setError(true);
});
};
const handleSubmit = async () => {
for (const folderName of selectedFolderNames) {
const folder = folders.find((folder) => folder.name === folderName);
const folder = availableFolders.find((f) => f.name === folderName);
await axiosConfig
.post(
`/GrantUserAccessToFolder?UserId=${props.current_user.id}&FolderId=${folder.id}`
)
.then((response) => {
if (response) {
setUpdatedMessage(
'Granted access to user ' + props.current_user.name
);
setUpdated(true);
}
.post(`/GrantUserAccessToFolder?UserId=${props.current_user.id}&FolderId=${folder.id}`)
.then(() => {
setUpdatedMessage(intl.formatMessage({ id: 'grantedAccessToUser' }, { name: props.current_user.name }));
setUpdated(true);
})
.catch((err) => {
if (err.response && err.response.status === 401) {
setErrorMessage(
`User ${props.current_user.name} already has access to folder "${folder.name}" or you don't have permission to grant this access`
);
} else {
setErrorMessage('An error has occured');
}
.catch(() => {
setErrorMessage(intl.formatMessage({ id: 'errorOccured' }));
setError(true);
});
}
for (const installationName of selectedInstallationNames) {
const installation = installations.find(
(installation) => installation.name === installationName
);
const installation = availableInstallations.find((i) => i.name === installationName);
await axiosConfig
.post(
`/GrantUserAccessToInstallation?UserId=${props.current_user.id}&InstallationId=${installation.id}`
)
.then((response) => {
if (response) {
setUpdatedMessage(
'Granted access to user ' + props.current_user.name
);
setUpdated(true);
}
.post(`/GrantUserAccessToInstallation?UserId=${props.current_user.id}&InstallationId=${installation.id}`)
.then(() => {
setUpdatedMessage(intl.formatMessage({ id: 'grantedAccessToUser' }, { name: props.current_user.name }));
setUpdated(true);
})
.catch((err) => {
if (err.response && err.response.status === 401) {
setErrorMessage(
`User ${props.current_user.name} already has access to installation "${installation.name}" or you don't have permission to grant this access`
);
} else {
setErrorMessage('An error has occured');
}
.catch(() => {
setErrorMessage(intl.formatMessage({ id: 'errorOccured' }));
setError(true);
});
}
setOpenModal(false);
fetchInstallationsForUser(props.current_user.id);
fetchDirectGrants();
};
return (
@ -221,51 +199,25 @@ function UserAccess(props: UserAccessProps) {
<Grid container>
<Grid item xs={12} md={12}>
{updated && (
<Alert
severity="success"
sx={{
mt: 1
}}
>
<Alert severity="success" sx={{ mt: 1 }}>
{updatedmessage}
<IconButton
color="inherit"
size="small"
onClick={() => setUpdated(false)}
>
<IconButton color="inherit" size="small" onClick={() => setUpdated(false)}>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
{error && (
<Alert
severity="error"
sx={{
marginTop: '20px',
marginBottom: '20px'
}}
>
<Alert severity="error" sx={{ marginTop: '20px', marginBottom: '20px' }}>
{errormessage}
<IconButton
color="inherit"
size="small"
onClick={() => setError(false)}
sx={{
marginLeft: '10px'
}}
>
<IconButton color="inherit" size="small" onClick={() => setError(false)} sx={{ marginLeft: '10px' }}>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
<Modal
open={openModal}
onClose={() => {}}
aria-labelledby="error-modal"
aria-describedby="error-modal-description"
>
{/* Grant Access Modal */}
<Modal open={openModal} onClose={() => {}} aria-labelledby="grant-modal">
<Box
sx={{
position: 'absolute',
@ -279,59 +231,29 @@ function UserAccess(props: UserAccessProps) {
p: 4
}}
>
<Box
component="form"
sx={{
textAlign: 'center'
}}
noValidate
autoComplete="off"
>
<Box component="form" sx={{ textAlign: 'center' }} noValidate autoComplete="off">
<div>
<FormControl fullWidth sx={{ marginTop: 1, width: 390 }}>
<InputLabel
sx={{
fontSize: 14,
backgroundColor: 'white'
}}
>
<FormattedMessage
id="grantAccessToFolders"
defaultMessage="Grant access to folders"
/>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
<FormattedMessage id="grantAccessToFolders" defaultMessage="Grant access to folders" />
</InputLabel>
<Select
multiple
value={selectedFolderNames}
onChange={handleFolderChange}
onChange={(e) => setSelectedFolderNames(e.target.value as string[])}
open={openFolder}
onClose={handleCloseFolder}
onOpen={handleOpenFolder}
onClose={() => setOpenFolder(false)}
onOpen={() => setOpenFolder(true)}
renderValue={(selected) => (
<div>
{selected.map((folder) => (
<span key={folder}>{folder}, </span>
))}
</div>
<div>{selected.map((f) => <span key={f}>{f}, </span>)}</div>
)}
>
{folders.map((folder) => (
<MenuItem key={folder.id} value={folder.name}>
{folder.name}
</MenuItem>
{availableFolders.map((folder) => (
<MenuItem key={folder.id} value={folder.name}>{folder.name}</MenuItem>
))}
<Button
sx={{
marginLeft: '150px',
marginTop: '10px',
backgroundColor: theme.colors.primary.main,
color: 'white',
'&:hover': {
backgroundColor: theme.colors.primary.dark
},
padding: '6px 8px'
}}
onClick={handleCloseFolder}
sx={{ marginLeft: '150px', marginTop: '10px', backgroundColor: theme.colors.primary.main, color: 'white', '&:hover': { backgroundColor: theme.colors.primary.dark }, padding: '6px 8px' }}
onClick={() => setOpenFolder(false)}
>
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
@ -341,52 +263,26 @@ function UserAccess(props: UserAccessProps) {
<div>
<FormControl fullWidth sx={{ marginTop: 2, width: 390 }}>
<InputLabel
sx={{
fontSize: 14,
backgroundColor: 'white'
}}
>
<FormattedMessage
id="grantAccessToInstallations"
defaultMessage="Grant access to installations"
/>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
<FormattedMessage id="grantAccessToInstallations" defaultMessage="Grant access to installations" />
</InputLabel>
<Select
multiple
value={selectedInstallationNames}
onChange={handleInstallationChange}
onChange={(e) => setSelectedInstallationNames(e.target.value as string[])}
open={openInstallation}
onClose={handleCloseInstallation}
onOpen={handleOpenInstallation}
onClose={() => setOpenInstallation(false)}
onOpen={() => setOpenInstallation(true)}
renderValue={(selected) => (
<div>
{selected.map((installation) => (
<span key={installation}>{installation}, </span>
))}
</div>
<div>{selected.map((i) => <span key={i}>{i}, </span>)}</div>
)}
>
{installations.map((installation) => (
<MenuItem
key={installation.id}
value={installation.name}
>
{installation.name}
</MenuItem>
{availableInstallations.map((installation) => (
<MenuItem key={installation.id} value={installation.name}>{installation.name}</MenuItem>
))}
<Button
sx={{
marginLeft: '150px',
marginTop: '10px',
backgroundColor: theme.colors.primary.main,
color: 'white',
'&:hover': {
backgroundColor: theme.colors.primary.dark
},
padding: '6px 8px'
}}
onClick={handleCloseInstallation}
sx={{ marginLeft: '150px', marginTop: '10px', backgroundColor: theme.colors.primary.main, color: 'white', '&:hover': { backgroundColor: theme.colors.primary.dark }, padding: '6px 8px' }}
onClick={() => setOpenInstallation(false)}
>
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
@ -395,32 +291,15 @@ function UserAccess(props: UserAccessProps) {
</div>
<Button
sx={{
marginTop: '20px',
backgroundColor: theme.colors.primary.main,
color: 'white',
'&:hover': {
backgroundColor: theme.colors.primary.dark
},
padding: '6px 8px'
}}
sx={{ marginTop: '20px', backgroundColor: theme.colors.primary.main, color: 'white', '&:hover': { backgroundColor: theme.colors.primary.dark }, padding: '6px 8px' }}
onClick={handleSubmit}
>
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
<Button
variant="contained"
onClick={handleCancel}
sx={{
marginTop: '20px',
marginLeft: '10px',
backgroundColor: theme.colors.primary.main,
color: 'white',
'&:hover': {
backgroundColor: theme.colors.primary.dark
},
padding: '6px 8px'
}}
onClick={() => setOpenModal(false)}
sx={{ marginTop: '20px', marginLeft: '10px', backgroundColor: theme.colors.primary.main, color: 'white', '&:hover': { backgroundColor: theme.colors.primary.dark }, padding: '6px 8px' }}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
@ -431,43 +310,63 @@ function UserAccess(props: UserAccessProps) {
<Button
variant="contained"
onClick={handleGrantAccess}
sx={{
marginTop: '20px',
marginBottom: '20px',
backgroundColor: '#ffc04d',
color: '#000000',
'&:hover': { bgcolor: '#f7b34d' }
}}
sx={{ marginTop: '20px', marginBottom: '20px', backgroundColor: '#ffc04d', color: '#000000', '&:hover': { bgcolor: '#f7b34d' } }}
>
<FormattedMessage id="grantAccess" defaultMessage="Grant Access" />
</Button>
</Grid>
<Grid item xs={12} md={12}>
{accessibleInstallationsForUser.map((installation, index) => {
const isLast = index === accessibleInstallationsForUser.length - 1;
{/* Folder Access Section */}
<Grid item xs={12} md={12}>
<Typography variant="subtitle2" sx={{ mt: 1, mb: 0.5, color: 'text.secondary', fontWeight: 600 }}>
<FormattedMessage id="folderAccess" defaultMessage="Folder Access" />
</Typography>
{directFolders.map((folder, index) => {
const isLast = index === directFolders.length - 1;
return (
<Fragment key={installation.name}>
<Fragment key={folder.id}>
<ListItem
sx={{
mb: isLast ? 4 : 0 // Apply margin-bottom to the last item only
}}
sx={{ mb: isLast ? 1 : 0 }}
secondaryAction={
currentUser.userType === UserType.admin && (
<IconButton
onClick={() => {
RevokeAccessFromResource(
'ToInstallation',
props.current_user.id,
'InstallationId',
installation.id,
props.current_user.name
);
<IconButton onClick={() => handleRevokeFolder(folder.id, folder.name)} edge="end">
<PersonRemoveIcon />
</IconButton>
)
}
>
<ListItemAvatar>
<Avatar>
<FolderIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={folder.name} />
</ListItem>
<Divider />
</Fragment>
);
})}
{directFolders.length === 0 && (
<Alert severity="info" sx={{ mb: 1 }}>
<FormattedMessage id="noDirectFolderAccess" defaultMessage="No folder access grants" />
</Alert>
)}
</Grid>
fetchInstallationsForUser(props.current_user.id);
}}
edge="end"
>
{/* Direct Installation Access Section */}
<Grid item xs={12} md={12}>
<Typography variant="subtitle2" sx={{ mt: 2, mb: 0.5, color: 'text.secondary', fontWeight: 600 }}>
<FormattedMessage id="directInstallationAccess" defaultMessage="Direct Installation Access" />
</Typography>
{directInstallations.map((installation, index) => {
const isLast = index === directInstallations.length - 1;
return (
<Fragment key={installation.id}>
<ListItem
sx={{ mb: isLast ? 4 : 0 }}
secondaryAction={
currentUser.userType === UserType.admin && (
<IconButton onClick={() => handleRevokeInstallation(installation.id)} edge="end">
<PersonRemoveIcon />
</IconButton>
)
@ -484,22 +383,9 @@ function UserAccess(props: UserAccessProps) {
</Fragment>
);
})}
{accessibleInstallationsForUser.length == 0 && (
<Alert
severity="error"
sx={{
display: 'flex',
alignItems: 'center',
marginTop: '20px',
marginBottom: '20px'
}}
>
<FormattedMessage
id="theUserDoesNOtHaveAccessToAnyInstallation"
defaultMessage="The user does not have access to any installation "
/>
<IconButton color="inherit" size="small"></IconButton>
{directInstallations.length === 0 && (
<Alert severity="info" sx={{ mb: 4 }}>
<FormattedMessage id="noDirectInstallationAccess" defaultMessage="No direct installation access grants" />
</Alert>
)}
</Grid>

View File

@ -621,7 +621,7 @@ function Overview(props: OverviewProps) {
</Container>
)}
{!loading && (
{!loading && dailyDataArray.length > 0 && (
<Grid item xs={12} md={12}>
{dailyData && (
<Grid

View File

@ -346,6 +346,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
<Log
errorLoadingS3Data={errorLoadingS3Data}
id={props.current_installation.id}
status={props.current_installation.status}
></Log>
}
/>

View File

@ -99,6 +99,9 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<TableCell>
<FormattedMessage id="location" defaultMessage="Location" />
</TableCell>
<TableCell>
<FormattedMessage id="installationSN" defaultMessage="Installation SN" />
</TableCell>
<TableCell>
<FormattedMessage id="region" defaultMessage="Region" />
</TableCell>
@ -156,6 +159,19 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.serialNumber}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"

View File

@ -27,6 +27,7 @@ import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome';
import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
import Overview from '../Overview/overview';
import WeeklyReport from './WeeklyReport';
interface singleInstallationProps {
current_installation?: I_Installation;
@ -430,7 +431,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
currentTab != 'overview' &&
currentTab != 'manage' &&
currentTab != 'history' &&
currentTab != 'log' && (
currentTab != 'log' &&
currentTab != 'report' && (
<Container
maxWidth="xl"
sx={{
@ -472,15 +474,18 @@ function SodioHomeInstallation(props: singleInstallationProps) {
}
/>
<Route
path={routes.log}
element={
<Log
errorLoadingS3Data={errorLoadingS3Data}
id={props.current_installation.id}
></Log>
}
/>
{currentUser.userType !== UserType.client && (
<Route
path={routes.log}
element={
<Log
errorLoadingS3Data={errorLoadingS3Data}
id={props.current_installation.id}
status={props.current_installation.status}
></Log>
}
/>
)}
<Route
path={routes.live}
@ -494,18 +499,20 @@ function SodioHomeInstallation(props: singleInstallationProps) {
}
/>
<Route
path={routes.batteryview + '/*'}
element={
<BatteryViewSodioHome
values={values}
s3Credentials={s3Credentials}
installationId={props.current_installation.id}
installation={props.current_installation}
connected={connected}
></BatteryViewSodioHome>
}
></Route>
{currentUser.userType !== UserType.client && (
<Route
path={routes.batteryview + '/*'}
element={
<BatteryViewSodioHome
values={values}
s3Credentials={s3Credentials}
installationId={props.current_installation.id}
installation={props.current_installation}
connected={connected}
></BatteryViewSodioHome>
}
/>
)}
{currentUser.userType == UserType.admin && (
<Route
@ -556,6 +563,15 @@ function SodioHomeInstallation(props: singleInstallationProps) {
}
/>
<Route
path={routes.report}
element={
<WeeklyReport
installationId={props.current_installation.id}
/>
}
/>
<Route
path={'*'}
element={<Navigate to={routes.live}></Navigate>}

View File

@ -0,0 +1,474 @@
import { useEffect, useState } from 'react';
import { useIntl, FormattedMessage } from 'react-intl';
import {
Box,
Button,
CircularProgress,
Container,
Grid,
Paper,
TextField,
Typography,
Alert
} from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import axiosConfig from 'src/Resources/axiosConfig';
interface WeeklyReportProps {
installationId: number;
}
interface DailyEnergyData {
date: string;
pvProduction: number;
loadConsumption: number;
gridImport: number;
gridExport: number;
batteryCharged: number;
batteryDischarged: number;
}
interface WeeklySummary {
totalPvProduction: number;
totalConsumption: number;
totalGridImport: number;
totalGridExport: number;
totalBatteryCharged: number;
totalBatteryDischarged: number;
}
interface WeeklyReportResponse {
installationName: string;
periodStart: string;
periodEnd: string;
currentWeek: WeeklySummary;
previousWeek: WeeklySummary | null;
totalEnergySaved: number;
totalSavingsCHF: number;
daysEquivalent: number;
selfSufficiencyPercent: number;
selfConsumptionPercent: number;
batteryEfficiencyPercent: number;
gridDependencyPercent: number;
pvChangePercent: number;
consumptionChangePercent: number;
gridImportChangePercent: number;
dailyData: DailyEnergyData[];
aiInsight: string;
}
// Matches: time ranges (14:0018:00), times (09:00), decimals (126.4 / 1,3), integers (34)
// Any number in any language gets bolded — no unit matching needed
const BOLD_PATTERN = /(\d{1,2}:\d{2}(?:[\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)/g;
const isBold = (s: string) => /\d/.test(s);
// Renders a bullet line: bolds the "Title" part before the first colon, and numbers with units
function FormattedBullet({ text }: { text: string }) {
const colonIdx = text.indexOf(':');
if (colonIdx > 0) {
const title = text.slice(0, colonIdx);
const rest = text.slice(colonIdx + 1); // e.g. " This week, your system saved 120.9 kWh..."
const restParts = rest.split(BOLD_PATTERN).map((p, i) =>
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
);
return <><strong>{title}</strong>:{restParts}</>;
}
// No colon — just bold figures
const parts = text.split(BOLD_PATTERN).map((p, i) =>
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
);
return <>{parts}</>;
}
function WeeklyReport({ installationId }: WeeklyReportProps) {
const intl = useIntl();
const [report, setReport] = useState<WeeklyReportResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState('');
const [sending, setSending] = useState(false);
const [sendStatus, setSendStatus] = useState<{
message: string;
severity: 'success' | 'error';
} | null>(null);
useEffect(() => {
fetchReport();
}, [installationId, intl.locale]);
const fetchReport = async () => {
setLoading(true);
setError(null);
try {
const res = await axiosConfig.get('/GetWeeklyReport', {
params: { installationId, language: intl.locale }
});
setReport(res.data);
} catch (err: any) {
const msg =
err.response?.data ||
err.message ||
'Failed to load report. Make sure the Excel file is placed in tmp_report/';
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
}
};
const handleSendEmail = async () => {
if (!email.trim()) return;
setSending(true);
try {
await axiosConfig.post('/SendWeeklyReportEmail', null, {
params: { installationId, emailAddress: email.trim() }
});
setSendStatus({ message: intl.formatMessage({ id: 'reportSentTo' }, { email }), severity: 'success' });
} catch (err: any) {
setSendStatus({ message: intl.formatMessage({ id: 'reportSendError' }), severity: 'error' });
} finally {
setSending(false);
}
};
if (loading) {
return (
<Container
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '50vh'
}}
>
<CircularProgress size={50} style={{ color: '#ffc04d' }} />
<Typography variant="body2" mt={2}>
<FormattedMessage id="generatingReport" defaultMessage="Generating weekly report..." />
</Typography>
</Container>
);
}
if (error) {
return (
<Container sx={{ py: 4 }}>
<Alert severity="warning"><FormattedMessage id="noReportData" defaultMessage="No report data found." /></Alert>
</Container>
);
}
if (!report) return null;
const cur = report.currentWeek;
const prev = report.previousWeek;
// Backend: currentWeek = last 7 days, previousWeek = everything before
const currentWeekDayCount = Math.min(7, report.dailyData.length);
const previousWeekDayCount = Math.max(1, report.dailyData.length - currentWeekDayCount);
const formatChange = (pct: number) =>
pct === 0 ? '—' : pct > 0 ? `+${pct.toFixed(1)}%` : `${pct.toFixed(1)}%`;
const changeColor = (pct: number, invert = false) => {
const effective = invert ? -pct : pct;
return effective > 0 ? '#27ae60' : effective < 0 ? '#e74c3c' : '#888';
};
// Parse AI insight into bullet points
const insightBullets = report.aiInsight
.split(/\n+/)
.map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim())
.filter((line) => line.length > 0);
// Read pre-computed values from backend — no arithmetic in the frontend
const totalEnergySavedKwh = report.totalEnergySaved;
const totalSavingsCHF = report.totalSavingsCHF;
// Find max value for daily bar chart scaling
const maxDailyValue = Math.max(
...report.dailyData.map((d) => Math.max(d.pvProduction, d.loadConsumption, d.gridImport)),
1
);
return (
<Box sx={{ p: 2 }}>
{/* Email bar */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', mb: 3, gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
placeholder="recipient@example.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setSendStatus(null); }}
sx={{ width: 280 }}
onKeyDown={(e) => e.key === 'Enter' && handleSendEmail()}
/>
<Button
variant="contained"
startIcon={sending ? <CircularProgress size={16} color="inherit" /> : <SendIcon />}
onClick={handleSendEmail}
disabled={sending || !email.trim()}
sx={{ bgcolor: '#2c3e50', '&:hover': { bgcolor: '#34495e' } }}
>
<FormattedMessage id="sendReport" defaultMessage="Send Report" />
</Button>
</Box>
{sendStatus && (
<Typography variant="caption" sx={{ color: sendStatus.severity === 'success' ? '#27ae60' : '#e74c3c' }}>
{sendStatus.message}
</Typography>
)}
</Box>
{/* Report Header */}
<Paper
sx={{
bgcolor: '#2c3e50',
color: '#fff',
p: 3,
mb: 3,
borderRadius: 2
}}
>
<Typography variant="h5" fontWeight="bold">
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
</Typography>
<Typography variant="body1" sx={{ opacity: 0.9, mt: 0.5 }}>
{report.installationName}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.7 }}>
{report.periodStart} {report.periodEnd}
</Typography>
</Paper>
{/* Weekly Insights (was AI Insights) */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="weeklyInsights" defaultMessage="Weekly Insights" />
</Typography>
<Box
sx={{
bgcolor: '#fef9e7',
borderLeft: '4px solid #f39c12',
p: 2.5,
borderRadius: '0 8px 8px 0'
}}
>
{insightBullets.length > 1 ? (
<Box component="ul" sx={{ m: 0, pl: 2.5, '& li': { mb: 1.2, lineHeight: 1.7, fontSize: '14px', color: '#333' } }}>
{insightBullets.map((bullet, i) => (
<li key={i}><FormattedBullet text={bullet} /></li>
))}
</Box>
) : (
<Typography sx={{ lineHeight: 1.7, fontSize: '14px', color: '#333' }}>
<FormattedBullet text={report.aiInsight} />
</Typography>
)}
</Box>
</Paper>
{/* Your Savings This Week */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="weeklySavings" defaultMessage="Your Savings This Week" />
</Typography>
<Grid container spacing={2}>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'solarEnergyUsed' })}
value={`${totalEnergySavedKwh} kWh`}
subtitle={intl.formatMessage({ id: 'solarStayedHome' })}
color="#27ae60"
hint={report.daysEquivalent > 0 ? `${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined}
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'estMoneySaved' })}
value={`~${totalSavingsCHF} CHF`}
subtitle={intl.formatMessage({ id: 'atCHFRate' })}
color="#2980b9"
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'solarCoverage' })}
value={`${report.selfSufficiencyPercent.toFixed(0)}%`}
subtitle={intl.formatMessage({ id: 'fromSolarSub' })}
color="#8e44ad"
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'batteryEfficiency' })}
value={`${report.batteryEfficiencyPercent.toFixed(0)}%`}
subtitle={intl.formatMessage({ id: 'batteryEffSub' })}
color="#e67e22"
/>
</Grid>
</Grid>
</Paper>
{/* Weekly Summary Table */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="weeklySummary" defaultMessage="Weekly Summary" />
</Typography>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', '& td, & th': { p: 1.2, borderBottom: '1px solid #eee' } }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ textAlign: 'left' }}><FormattedMessage id="metric" defaultMessage="Metric" /></th>
<th style={{ textAlign: 'right' }}><FormattedMessage id="thisWeek" defaultMessage="This Week" /></th>
{prev && <th style={{ textAlign: 'right' }}><FormattedMessage id="lastWeek" defaultMessage="Last Week" /></th>}
{prev && <th style={{ textAlign: 'right' }}><FormattedMessage id="change" defaultMessage="Change" /></th>}
</tr>
</thead>
<tbody>
<tr>
<td><FormattedMessage id="pvProduction" defaultMessage="PV Production" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalPvProduction.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalPvProduction.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: changeColor(report.pvChangePercent) }}>{formatChange(report.pvChangePercent)}</td>}
</tr>
<tr>
<td><FormattedMessage id="consumption" defaultMessage="Consumption" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalConsumption.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalConsumption.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: changeColor(report.consumptionChangePercent, true) }}>{formatChange(report.consumptionChangePercent)}</td>}
</tr>
<tr style={{ background: '#fafafa' }}>
<td style={{ color: '#888', paddingLeft: '20px', fontSize: '13px' }}>
<FormattedMessage id="avgDailyConsumption" defaultMessage="Avg Daily Consumption" />
</td>
<td style={{ textAlign: 'right', color: '#888', fontSize: '13px' }}>
{(cur.totalConsumption / currentWeekDayCount).toFixed(1)} kWh
</td>
{prev && <td style={{ textAlign: 'right', color: '#bbb', fontSize: '13px' }}>
{(prev.totalConsumption / previousWeekDayCount).toFixed(1)} kWh
</td>}
{prev && <td />}
</tr>
<tr>
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridImport.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalGridImport.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: changeColor(report.gridImportChangePercent, true) }}>{formatChange(report.gridImportChangePercent)}</td>}
</tr>
<tr>
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridExport.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalGridExport.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right' }}></td>}
</tr>
<tr>
<td><FormattedMessage id="batteryInOut" defaultMessage="Battery In / Out" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalBatteryCharged.toFixed(1)} / {cur.totalBatteryDischarged.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalBatteryCharged.toFixed(1)} / {prev.totalBatteryDischarged.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right' }}></td>}
</tr>
</tbody>
</Box>
</Paper>
{/* Daily Breakdown - CSS bar chart */}
{report.dailyData.length > 0 && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 1, color: '#2c3e50' }}>
<FormattedMessage id="dailyBreakdown" defaultMessage="Daily Breakdown" />
</Typography>
{/* Legend */}
<Box sx={{ display: 'flex', gap: 3, mb: 2, fontSize: '12px', color: '#666' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, bgcolor: '#f39c12', borderRadius: '2px' }} /> <FormattedMessage id="pvProduction" defaultMessage="PV Production" />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, bgcolor: '#3498db', borderRadius: '2px' }} /> <FormattedMessage id="consumption" defaultMessage="Consumption" />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, bgcolor: '#e74c3c', borderRadius: '2px' }} /> <FormattedMessage id="gridImport" defaultMessage="Grid Import" />
</Box>
</Box>
{/* Bars */}
{report.dailyData.map((d, i) => {
const dt = new Date(d.date);
const dayLabel = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
const isCurrentWeek = report.dailyData.length > 7 ? i >= report.dailyData.length - 7 : true;
return (
<Box key={d.date} sx={{ mb: 1.5, opacity: isCurrentWeek ? 1 : 0.6 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
<Typography variant="caption" sx={{ fontWeight: isCurrentWeek ? 'bold' : 'normal', color: '#444', fontSize: '12px' }}>
{dayLabel}
{!isCurrentWeek && <span style={{ color: '#999', marginLeft: 4 }}><FormattedMessage id="prevWeek" defaultMessage="(prev week)" /></span>}
</Typography>
<Typography variant="caption" sx={{ color: '#888', fontSize: '11px' }}>
PV {d.pvProduction.toFixed(1)} | Load {d.loadConsumption.toFixed(1)} | Grid {d.gridImport.toFixed(1)} kWh
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: '2px', height: 18 }}>
<Box
sx={{
width: `${(d.pvProduction / maxDailyValue) * 100}%`,
bgcolor: '#f39c12',
borderRadius: '2px 0 0 2px',
minWidth: d.pvProduction > 0 ? '2px' : 0,
transition: 'width 0.3s'
}}
/>
<Box
sx={{
width: `${(d.loadConsumption / maxDailyValue) * 100}%`,
bgcolor: '#3498db',
minWidth: d.loadConsumption > 0 ? '2px' : 0,
transition: 'width 0.3s'
}}
/>
<Box
sx={{
width: `${(d.gridImport / maxDailyValue) * 100}%`,
bgcolor: '#e74c3c',
borderRadius: '0 2px 2px 0',
minWidth: d.gridImport > 0 ? '2px' : 0,
transition: 'width 0.3s'
}}
/>
</Box>
</Box>
);
})}
</Paper>
)}
</Box>
);
}
function SavingsCard({ label, value, subtitle, color, hint }: { label: string; value: string; subtitle: string; color: string; hint?: string }) {
return (
<Box
sx={{
bgcolor: '#f8f9fa',
borderRadius: 2,
p: 2,
textAlign: 'center'
}}
>
<Typography variant="h4" fontWeight="bold" sx={{ color, fontSize: { xs: '1.5rem', sm: '2rem' } }}>
{value}
</Typography>
<Typography variant="body2" fontWeight="bold" sx={{ color: '#444', mt: 0.5 }}>
{label}
</Typography>
<Typography variant="caption" sx={{ color: '#888' }}>
{subtitle}
</Typography>
{hint && (
<Typography variant="caption" sx={{ display: 'block', color, fontWeight: 'bold', mt: 0.5 }}>
{hint}
</Typography>
)}
</Box>
);
}
export default WeeklyReport;

View File

@ -30,7 +30,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
'manage',
'log',
'history',
'configuration'
'configuration',
'report'
];
const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -117,7 +118,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
value: 'log',
label: <FormattedMessage id="log" defaultMessage="Log" />
},
{
value: 'manage',
label: (
@ -127,14 +127,12 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
/>
)
},
{
value: 'information',
label: (
<FormattedMessage id="information" defaultMessage="Information" />
)
},
{
value: 'configuration',
label: (
@ -152,6 +150,54 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
defaultMessage="History Of Actions"
/>
)
},
{
value: 'report',
label: (
<FormattedMessage
id="report"
defaultMessage="Report"
/>
)
}
]
: currentUser.userType == UserType.partner
? [
{
value: 'live',
label: <FormattedMessage id="live" defaultMessage="Live" />
},
{
value: 'overview',
label: <FormattedMessage id="overview" defaultMessage="Overview" />
},
{
value: 'batteryview',
label: (
<FormattedMessage
id="batteryview"
defaultMessage="Battery View"
/>
)
},
{
value: 'log',
label: <FormattedMessage id="log" defaultMessage="Log" />
},
{
value: 'information',
label: (
<FormattedMessage id="information" defaultMessage="Information" />
)
},
{
value: 'report',
label: (
<FormattedMessage
id="report"
defaultMessage="Report"
/>
)
}
]
: [
@ -168,14 +214,24 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
label: (
<FormattedMessage id="information" defaultMessage="Information" />
)
},
{
value: 'report',
label: (
<FormattedMessage
id="report"
defaultMessage="Report"
/>
)
}
];
const tabs =
const inInstallationView =
currentTab != 'list' &&
currentTab != 'tree' &&
!location.pathname.includes('folder') &&
currentUser.userType == UserType.admin
!location.pathname.includes('folder');
const tabs = inInstallationView && currentUser.userType == UserType.admin
? [
{
value: 'list',
@ -206,7 +262,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
value: 'log',
label: <FormattedMessage id="log" defaultMessage="Log" />
},
{
value: 'manage',
label: (
@ -216,7 +271,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
/>
)
},
{
value: 'information',
label: (
@ -240,12 +294,18 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
defaultMessage="History Of Actions"
/>
)
},
{
value: 'report',
label: (
<FormattedMessage
id="report"
defaultMessage="Report"
/>
)
}
]
: currentTab != 'list' &&
currentTab != 'tree' &&
!location.pathname.includes('folder') &&
currentUser.userType == UserType.client
: inInstallationView && currentUser.userType == UserType.partner
? [
{
value: 'list',
@ -263,12 +323,67 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
value: 'overview',
label: <FormattedMessage id="overview" defaultMessage="Overview" />
},
{
value: 'batteryview',
label: (
<FormattedMessage
id="batteryview"
defaultMessage="Battery View"
/>
)
},
{
value: 'log',
label: <FormattedMessage id="log" defaultMessage="Log" />
},
{
value: 'information',
label: (
<FormattedMessage id="information" defaultMessage="Information" />
)
},
{
value: 'report',
label: (
<FormattedMessage
id="report"
defaultMessage="Report"
/>
)
}
]
: inInstallationView && currentUser.userType == UserType.client
? [
{
value: 'list',
icon: <ListIcon id="mode-toggle-button-list-icon" />
},
{
value: 'tree',
icon: <AccountTreeIcon id="mode-toggle-button-tree-icon" />
},
{
value: 'live',
label: <FormattedMessage id="live" defaultMessage="Live" />
},
{
value: 'overview',
label: <FormattedMessage id="overview" defaultMessage="Overview" />
},
{
value: 'information',
label: (
<FormattedMessage id="information" defaultMessage="Information" />
)
},
{
value: 'report',
label: (
<FormattedMessage
id="report"
defaultMessage="Report"
/>
)
}
]
: [

View File

@ -1,15 +1,19 @@
import React, { ReactNode, useContext, useState } from 'react';
import { CircularProgress, ListItemIcon, useTheme } from '@mui/material';
import { CircularProgress, IconButton, ListItemIcon, useTheme } from '@mui/material';
import { TreeItem } from '@mui/lab';
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
import FolderIcon from '@mui/icons-material/Folder';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined';
import Typography from '@mui/material/Typography';
import { makeStyles } from '@mui/styles';
import CancelIcon from '@mui/icons-material/Cancel';
import routes from 'src/Resources/routes.json';
import { useLocation, useNavigate } from 'react-router-dom';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
import { UserContext } from '../../../contexts/userContext';
import { UserType } from '../../../interfaces/UserTypes';
import MoveModal from './MoveModal';
interface CustomTreeItemProps {
node: I_Installation | I_Folder;
@ -41,8 +45,10 @@ function CustomTreeItem(props: CustomTreeItemProps) {
const status = props.node.status;
const navigate = useNavigate();
const [selected, setSelected] = useState(false);
const [openMoveModal, setOpenMoveModal] = useState(false);
const currentLocation = useLocation();
const { product } = useContext(ProductIdContext);
const { currentUser } = useContext(UserContext);
const handleSelectOneInstallation = (): void => {
let installation = props.node;
@ -126,6 +132,24 @@ function CustomTreeItem(props: CustomTreeItemProps) {
{props.node.name}
</Typography>
{currentUser.userType === UserType.admin && (
<div onClick={(e) => e.stopPropagation()}>
<IconButton
size="small"
onClick={() => setOpenMoveModal(true)}
sx={{ ml: 1 }}
>
<DriveFileMoveOutlinedIcon fontSize="small" />
</IconButton>
{openMoveModal && (
<MoveModal
node={props.node}
onClose={() => setOpenMoveModal(false)}
/>
)}
</div>
)}
{props.node.type === 'Installation' && (
<div>
{status === -1 ? (

View File

@ -44,12 +44,12 @@ function Folder(props: singleFolderProps) {
value: 'information',
label: <FormattedMessage id="information" defaultMessage="Information" />
},
{
...(currentUser.userType === UserType.admin ? [{
value: 'manage',
label: (
<FormattedMessage id="manageAccess" defaultMessage="Manage Access" />
)
}
}] : [])
];
const handleTabsChange = (_event: ChangeEvent<{}>, value: string): void => {

View File

@ -0,0 +1,165 @@
import React, { useContext, useState } from 'react';
import {
Alert,
Box,
CircularProgress,
FormControl,
IconButton,
InputLabel,
MenuItem,
Modal,
Select,
Typography,
useTheme
} from '@mui/material';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
import { ProductIdContext } from 'src/contexts/ProductIdContextProvider';
import { FormattedMessage } from 'react-intl';
interface MoveModalProps {
node: I_Installation | I_Folder;
onClose: () => void;
}
// Returns the IDs of a folder and all its descendants (to prevent circular moves)
function getDescendantIds(folderId: number, allItems: any[]): Set<number> {
const result = new Set<number>([folderId]);
const queue = [folderId];
while (queue.length > 0) {
const currentId = queue.shift();
allItems
.filter((item) => item.parentId === currentId && item.type === 'Folder')
.forEach((child) => {
result.add(child.id);
queue.push(child.id);
});
}
return result;
}
function MoveModal(props: MoveModalProps) {
const theme = useTheme();
const { foldersAndInstallations, moveInstallation, moveFolder, loading, setLoading, error, setError } =
useContext(InstallationsContext);
const { product } = useContext(ProductIdContext);
const [selectedFolderId, setSelectedFolderId] = useState<number | ''>('');
// For folders: exclude self and all descendants to prevent circular nesting
// For installations: any folder is valid
const excludedIds =
props.node.type === 'Folder'
? getDescendantIds(props.node.id, foldersAndInstallations)
: new Set<number>();
const availableFolders = foldersAndInstallations.filter(
(item) => item.type === 'Folder' && !excludedIds.has(item.id)
);
const handleSubmit = async () => {
if (selectedFolderId === '') return;
setLoading(true);
setError(false);
if (props.node.type === 'Installation') {
await moveInstallation(props.node.id, selectedFolderId as number, product);
} else {
await moveFolder(props.node.id, selectedFolderId as number, product);
}
setLoading(false);
props.onClose();
};
return (
<Modal
open={true}
onClose={() => {}}
aria-labelledby="move-modal"
>
<Box
sx={{
position: 'absolute',
top: '40%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 500,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: 24,
p: 4
}}
>
<Typography variant="subtitle1" fontWeight="bold" mb={2}>
<FormattedMessage id="moveTo" defaultMessage="Move to" />:{' '}
<span style={{ color: theme.palette.primary.main }}>
{props.node.name}
</span>
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
<FormattedMessage id="folder" defaultMessage="Folder" />
</InputLabel>
<Select
value={selectedFolderId}
onChange={(e) => setSelectedFolderId(e.target.value as number)}
>
{availableFolders.map((folder: I_Folder) => (
<MenuItem key={folder.id} value={folder.id}>
{folder.name}
</MenuItem>
))}
</Select>
</FormControl>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 10 }}>
<Button
variant="contained"
onClick={handleSubmit}
disabled={selectedFolderId === ''}
sx={{ marginLeft: '10px' }}
>
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
<Button
variant="contained"
onClick={props.onClose}
sx={{ marginLeft: '10px' }}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
{loading && (
<CircularProgress
sx={{ color: theme.palette.primary.main, marginLeft: '20px' }}
/>
)}
{error && (
<Alert
severity="error"
sx={{ ml: 1, display: 'flex', alignItems: 'center' }}
>
<FormattedMessage
id="errorOccured"
defaultMessage="An error has occurred"
/>
<IconButton
color="inherit"
size="small"
onClick={() => setError(false)}
sx={{ marginLeft: '4px' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
</div>
</Box>
</Modal>
);
}
export default MoveModal;

View File

@ -11,7 +11,7 @@ import {
I_UserWithInheritedAccess,
InnovEnergyUser
} from '../interfaces/UserTypes';
import { FormattedMessage } from 'react-intl';
import { useIntl } from 'react-intl';
import { I_Installation } from '../interfaces/InstallationTypes';
interface AccessContextProviderProps {
@ -69,10 +69,11 @@ export const AccessContext = createContext<AccessContextProviderProps>({
});
const AccessContextProvider = ({ children }: { children: ReactNode }) => {
const intl = useIntl();
const [error, setError] = useState(false);
const [errormessage, setErrorMessage] = useState('An error has occured');
const [errormessage, setErrorMessage] = useState('');
const [updated, setUpdated] = useState(false);
const [updatedmessage, setUpdatedMessage] = useState('Successfully updated');
const [updatedmessage, setUpdatedMessage] = useState('');
const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext;
const [usersWithDirectAccess, setUsersWithDirectAccess] = useState<
@ -95,20 +96,12 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
setUsersWithDirectAccess(response.data);
}
})
.catch((error) => {
.catch(() => {
setError(true);
const message = (
<FormattedMessage
id="unableToLoadData"
defaultMessage="Unable to load data"
/>
).props.defaultMessage;
setErrorMessage(message);
setErrorMessage(intl.formatMessage({ id: 'unableToLoadData' }));
});
},
[]
[intl]
);
const fetchInstallationsForUser = useCallback(async (userId: number) => {
@ -119,17 +112,11 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
setAccessibleInstallationsForUser(response.data);
}
})
.catch((error) => {
.catch(() => {
setError(true);
const message = (
<FormattedMessage
id="unableToLoadData"
defaultMessage="Unable to load data"
/>
).props.defaultMessage;
setErrorMessage(message);
setErrorMessage(intl.formatMessage({ id: 'unableToLoadData' }));
});
}, []);
}, [intl]);
const fetchUsersWithInheritedAccessForResource = useCallback(
async (tempresourceType: string, id: number) => {
@ -140,18 +127,12 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
setUsersWithInheritedAccess(response.data);
}
})
.catch((error) => {
.catch(() => {
setError(true);
const message = (
<FormattedMessage
id="unableToLoadData"
defaultMessage="Unable to load data"
/>
).props.defaultMessage;
setErrorMessage(message);
setErrorMessage(intl.formatMessage({ id: 'unableToLoadData' }));
});
},
[]
[intl]
);
const fetchAvailableUsers = async (): Promise<void> => {
@ -183,17 +164,7 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
current_ResourceId
);
const message =
(
<FormattedMessage
id="revokedAccessFromUser"
defaultMessage="Revoked access from user: "
/>
).props.defaultMessage +
' ' +
name;
setUpdatedMessage(message);
setUpdatedMessage(intl.formatMessage({ id: 'revokedAccessFromUser' }) + ' ' + name);
setUpdated(true);
setTimeout(() => {
@ -201,19 +172,12 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => {
}, 3000);
}
})
.catch((error) => {
.catch(() => {
setError(true);
const message = (
<FormattedMessage
id="unableToRevokeAccess"
defaultMessage="Unable to revoke access"
/>
).props.defaultMessage;
setErrorMessage(message);
setErrorMessage(intl.formatMessage({ id: 'unableToRevokeAccess' }));
});
},
[]
[intl, fetchUsersWithDirectAccessForResource, fetchUsersWithInheritedAccessForResource]
);
return (

View File

@ -349,6 +349,42 @@ const InstallationsContextProvider = ({
[fetchAllFoldersAndInstallations, navigate, removeToken]
);
const moveInstallation = useCallback(
async (installationId: number, parentId: number, product: number) => {
try {
await axiosConfig.put(
`/MoveInstallation?installationId=${installationId}&parentId=${parentId}`
);
await fetchAllFoldersAndInstallations(product);
} catch (error) {
setError(true);
if (error.response?.status === 401) {
removeToken();
navigate(routes.login);
}
}
},
[fetchAllFoldersAndInstallations, navigate, removeToken]
);
const moveFolder = useCallback(
async (folderId: number, parentId: number, product: number) => {
try {
await axiosConfig.put(
`/MoveFolder?folderId=${folderId}&parentId=${parentId}`
);
await fetchAllFoldersAndInstallations(product);
} catch (error) {
setError(true);
if (error.response?.status === 401) {
removeToken();
navigate(routes.login);
}
}
},
[fetchAllFoldersAndInstallations, navigate, removeToken]
);
const contextValue = useMemo(
() => ({
salimax_or_sodistore_Installations,
@ -369,6 +405,8 @@ const InstallationsContextProvider = ({
createFolder,
updateFolder,
deleteFolder,
moveInstallation,
moveFolder,
//currentProduct,
socket,
openSocket,

View File

@ -31,3 +31,10 @@ export interface Action {
description: string;
testingMode: boolean;
}
export interface DiagnosticResponse {
name: string;
explanation: string;
causes: string[];
nextSteps: string[];
}

View File

@ -13,7 +13,9 @@
"english": "Englisch",
"error": "Fehler",
"folder": "Ordner",
"french": "Französisch",
"german": "Deutsch",
"italian": "Italienisch",
"groupTabs": "Gruppen",
"groupTree": "Gruppenbaum",
"overview": "Überblick",
@ -89,5 +91,292 @@
"unableToGrantAccess": "Der Zugriff kann nicht gewährt werden",
"unableToLoadData": "Daten können nicht geladen werden",
"unableToRevokeAccess": "Der Zugriff konnte nicht widerrufen werden",
"revokedAccessFromUser": "Zugriff vom Benutzer widerrufen"
"revokedAccessFromUser": "Zugriff vom Benutzer widerrufen",
"Show Errors": "Fehler anzeigen",
"Show Warnings": "Warnungen anzeigen",
"lastSeen": "Zuletzt gesehen",
"reportTitle": "Wöchentlicher Leistungsbericht",
"weeklyInsights": "Wöchentliche Einblicke",
"weeklySavings": "Ihre Einsparungen diese Woche",
"solarEnergyUsed": "Energie gespart",
"solarStayedHome": "Solar + Batterie, nicht vom Netz",
"daysOfYourUsage": "Tage Ihres Verbrauchs",
"estMoneySaved": "Geschätzte Ersparnisse",
"atCHFRate": "bei 0,39 CHF/kWh Ø",
"solarCoverage": "Eigenversorgung",
"fromSolarSub": "aus Solar + Batterie",
"avgDailyConsumption": "Ø Tagesverbrauch",
"batteryEfficiency": "Batterieeffizienz",
"batteryEffSub": "Entladung vs. Ladung",
"weeklySummary": "Wöchentliche Zusammenfassung",
"metric": "Kennzahl",
"thisWeek": "Diese Woche",
"change": "Änderung",
"pvProduction": "PV-Produktion",
"consumption": "Verbrauch",
"gridImport": "Netzbezug",
"gridExport": "Netzeinspeisung",
"batteryInOut": "Batterie Laden / Entladen",
"dailyBreakdown": "Tägliche Aufschlüsselung",
"prevWeek": "(Vorwoche)",
"sendReport": "Bericht senden",
"generatingReport": "Wochenbericht wird erstellt...",
"reportSentTo": "Bericht gesendet an {email}",
"reportSendError": "Senden fehlgeschlagen. Bitte überprüfen Sie die E-Mail-Adresse und versuchen Sie es erneut.",
"ok": "Ok",
"grantedAccessToUser": "Zugriff für Benutzer {name} gewährt",
"proceed": "Fortfahren",
"firmwareUpdating": "Firmware wird aktualisiert. Bitte warten...",
"confirmFirmwareUpdate": "Möchten Sie die Firmware wirklich aktualisieren?",
"batteryServiceStopWarning": "Diese Aktion erfordert, dass der Batteriedienst ca. 10-15 Minuten gestoppt wird.",
"downloadingBatteryLog": "Das Batterieprotokoll wird heruntergeladen. Es wird im Downloads-Ordner gespeichert. Bitte warten...",
"confirmBatteryLogDownload": "Möchten Sie das Batterieprotokoll wirklich herunterladen?",
"downloadBatteryLogFailed": "Herunterladen des Batterieprotokolls fehlgeschlagen, bitte versuchen Sie es erneut.",
"noReportData": "Keine Berichtsdaten gefunden.",
"ai_analyzing": "KI analysiert...",
"ai_show_details": "Details anzeigen",
"ai_show_less": "Weniger anzeigen",
"ai_likely_causes": "Wahrscheinliche Ursachen:",
"ai_next_steps": "Empfohlene nächste Schritte:",
"demo_test_button": "KI-Diagnose testen",
"demo_hide_button": "KI-Diagnose Demo ausblenden",
"demo_panel_title": "KI-Diagnose Demo",
"demo_custom_group": "Benutzerdefiniert (kann Mistral KI verwenden)",
"demo_custom_option": "Benutzerdefinierten Alarm eingeben…",
"demo_custom_placeholder": "z.B. UnknownBatteryFault",
"demo_diagnose_button": "Diagnostizieren",
"alarm_AbnormalGridVoltage": "Unnormale Netzspannung",
"alarm_AbnormalGridFrequency": "Unnormale Netzfrequenz",
"alarm_InvertedSequenceOfGridVoltage": "Falsche Phasenreihenfolge",
"alarm_GridVoltagePhaseLoss": "Phasenausfall im Netz",
"alarm_AbnormalGridCurrent": "Unnormaler Netzstrom",
"alarm_AbnormalOutputVoltage": "Ungewöhnliche Ausgangsspannung",
"alarm_AbnormalOutputFrequency": "Ungewöhnliche Ausgangsfrequenz",
"alarm_AbnormalNullLine": "Fehlerhafter Nullleiter",
"alarm_AbnormalOffGridOutputVoltage": "Ungewöhnliche Backup-Spannung",
"alarm_ExcessivelyHighAmbientTemperature": "Zu hohe Umgebungstemperatur",
"alarm_ExcessiveRadiatorTemperature": "Überhitzter Kühlkörper",
"alarm_PcbOvertemperature": "Überhitzte Leiterplatte",
"alarm_DcConverterOvertemperature": "Überhitzter DC-Wandler",
"alarm_InverterOvertemperatureAlarm": "Warnung: Überhitzung",
"alarm_InverterOvertemperature": "Wechselrichter überhitzt",
"alarm_DcConverterOvertemperatureAlarm": "Übertemperaturalarm DC-Wandler",
"alarm_InsulationFault": "Isolationsfehler",
"alarm_LeakageProtectionFault": "Leckschutzfehler",
"alarm_AbnormalLeakageSelfCheck": "Anomaler Leckstrom-Selbsttest",
"alarm_PoorGrounding": "Schlechte Erdung",
"alarm_FanFault": "Lüfterfehler",
"alarm_AuxiliaryPowerFault": "Hilfsstromversorgung Fehler",
"alarm_ModelCapacityFault": "Modellkapazitätsfehler",
"alarm_AbnormalLightningArrester": "Überspannungsschutz Fehler",
"alarm_IslandProtection": "Inselbetrieb Schutz",
"alarm_Battery1NotConnected": "Batterie 1 nicht verbunden",
"alarm_Battery1Overvoltage": "Batterie 1 Überspannung",
"alarm_Battery1Undervoltage": "Batterie 1 Unterspannung",
"alarm_Battery1DischargeEnd": "Batterie 1 Entladung beendet",
"alarm_Battery1Inverted": "Batterie 1 Polarität vertauscht",
"alarm_Battery1OverloadTimeout": "Batterie 1 Überlastung",
"alarm_Battery1SoftStartFailure": "Batterie 1 Startfehler",
"alarm_Battery1PowerTubeFault": "Batterie 1 Leistungsteil defekt",
"alarm_Battery1InsufficientPower": "Batterie 1 Leistung unzureichend",
"alarm_Battery1BackupProhibited": "Batterie 1 Backup gesperrt",
"alarm_Battery2NotConnected": "Batterie 2 nicht verbunden",
"alarm_Battery2Overvoltage": "Batterie 2 Überspannung",
"alarm_Battery2Undervoltage": "Batterie 2 Unterspannung",
"alarm_Battery2DischargeEnd": "Batterie 2 Entladung beendet",
"alarm_Battery2Inverted": "Batterie 2 falsch angeschlossen",
"alarm_Battery2OverloadTimeout": "Batterie 2 Überlastung",
"alarm_Battery2SoftStartFailure": "Batterie 2 Startfehler",
"alarm_Battery2PowerTubeFault": "Batterie 2 Leistungsteil defekt",
"alarm_Battery2InsufficientPower": "Batterie 2 Leistung unzureichend",
"alarm_Battery2BackupProhibited": "Batterie 2 Backup gesperrt",
"alarm_LithiumBattery1ChargeForbidden": "Lithium-Batterie 1 Ladeverbot",
"alarm_LithiumBattery1DischargeForbidden": "Lithium-Batterie 1 Entladeverbot",
"alarm_LithiumBattery2ChargeForbidden": "Lithium-Batterie 2 Ladeverbot",
"alarm_LithiumBattery2DischargeForbidden": "Lithium-Batterie 2 Entladeverbot",
"alarm_LithiumBattery1Full": "Lithium-Batterie 1 voll",
"alarm_LithiumBattery1DischargeEnd": "Lithium-Batterie 1 entladen",
"alarm_LithiumBattery2Full": "Lithium-Batterie 2 voll",
"alarm_LithiumBattery2DischargeEnd": "Lithium-Batterie 2 entladen",
"alarm_LeadBatteryTemperatureAbnormality": "Batterietemperatur abnormal",
"alarm_BatteryAccessMethodError": "Batteriezugriffsfehler",
"alarm_Pv1NotAccessed": "PV1 nicht erreichbar",
"alarm_Pv1Overvoltage": "PV1 Überspannung",
"alarm_AbnormalPv1CurrentSharing": "Ungleichmäßiger PV1-Strom",
"alarm_Pv1PowerTubeFault": "PV1 Leistungstubus defekt",
"alarm_Pv1SoftStartFailure": "PV1 Soft-Start fehlgeschlagen",
"alarm_Pv1OverloadTimeout": "PV1-Überlastung",
"alarm_Pv1InsufficientPower": "PV1-Schwacher Strom",
"alarm_Photovoltaic1Overcurrent": "PV1-Überstrom",
"alarm_Pv2NotAccessed": "PV2-Nicht erkannt",
"alarm_Pv2Overvoltage": "PV2-Überspannung",
"alarm_AbnormalPv2CurrentSharing": "Ungewöhnliche Stromverteilung PV2",
"alarm_Pv2PowerTubeFault": "PV2-Leistungsrohrfehler",
"alarm_Pv2SoftStartFailure": "PV2-Softstart fehlgeschlagen",
"alarm_Pv2OverloadTimeout": "PV2-Überlastung Timeout",
"alarm_Pv2InsufficientPower": "Unzureichende Leistung PV2",
"alarm_Pv3NotConnected": "PV3 nicht verbunden",
"alarm_Pv3Overvoltage": "PV3 Überspannung",
"alarm_Pv3AverageCurrentAnomaly": "PV3 Stromanomalie",
"alarm_Pv3PowerTubeFailure": "PV3 Leistungselektronik defekt",
"alarm_Pv3SoftStartFailure": "PV3 Startfehler",
"alarm_Pv3OverloadTimeout": "PV3-Überlastung",
"alarm_Pv3ReverseConnection": "PV3-Falschpolung",
"alarm_Pv4NotConnected": "PV4 Nicht Verbunden",
"alarm_Pv4Overvoltage": "PV4 Überspannung",
"alarm_Pv4AverageCurrentAnomaly": "PV4 Stromanomalie",
"alarm_Pv4PowerTubeFailure": "PV4-Leistungsrohr defekt",
"alarm_Pv4SoftStartFailure": "PV4-Softstart fehlgeschlagen",
"alarm_Pv4OverloadTimeout": "PV4-Überlastung",
"alarm_Pv4ReverseConnection": "PV4 falsch angeschlossen",
"alarm_InsufficientPhotovoltaicPower": "Zu wenig Solarstrom",
"alarm_DcBusOvervoltage": "DC-Bus Überspannung",
"alarm_DcBusUndervoltage": "DC-Bus Unterspannung",
"alarm_DcBusVoltageUnbalance": "DC-Bus Spannungsungleichgewicht",
"alarm_BusSlowOvervoltage": "Langsame DC-Bus Überspannung",
"alarm_HardwareBusOvervoltage": "Hardware DC-Bus Überspannung",
"alarm_BusSoftStartFailure": "Fehler beim sanften Start",
"alarm_InverterPowerTubeFault": "Wechselrichter-Leistungshalbleiter defekt",
"alarm_HardwareOvercurrent": "Hardware-Überstrom",
"alarm_DcConverterOvervoltage": "DC-Wandler Überspannung",
"alarm_DcConverterHardwareOvervoltage": "DC-Wandler Hardware-Überspannung",
"alarm_DcConverterOvercurrent": "DC-Wandler Überstrom",
"alarm_DcConverterHardwareOvercurrent": "DC-Wandler Hardware-Überstrom",
"alarm_DcConverterResonatorOvercurrent": "DC-Wandler Resonanz-Überstrom",
"alarm_SystemOutputOverload": "Systemausgang überlastet",
"alarm_InverterOverload": "Wechselrichter überlastet",
"alarm_InverterOverloadTimeout": "Wechselrichter-Überlastung",
"alarm_LoadPowerOverload": "Überlastung der Lastleistung",
"alarm_BalancedCircuitOverloadTimeout": "Phasenausgleich-Überlastung",
"alarm_InverterSoftStartFailure": "Wechselrichter-Softstart-Fehler",
"alarm_Dsp1ParameterSettingFault": "DSP-Parameter-Fehler",
"alarm_Dsp2ParameterSettingFault": "DSP2 Parameterfehler",
"alarm_DspVersionCompatibilityFault": "DSP-Versionen nicht kompatibel",
"alarm_CpldVersionCompatibilityFault": "CPLD-Version nicht kompatibel",
"alarm_CpldCommunicationFault": "CPLD-Kommunikationsfehler",
"alarm_DspCommunicationFault": "DSP-Kommunikationsfehler",
"alarm_OutputVoltageDcOverlimit": "DC-Spannung zu hoch",
"alarm_OutputCurrentDcOverlimit": "DC-Strom zu hoch",
"alarm_RelaySelfCheckFails": "Relais-Selbsttest fehlgeschlagen",
"alarm_InverterRelayOpen": "Wechselrichter-Relais offen",
"alarm_InverterRelayShortCircuit": "Wechselrichter-Relais Kurzschluss",
"alarm_OpenCircuitOfPowerGridRelay": "Netzrelais offen",
"alarm_ShortCircuitOfPowerGridRelay": "Netzrelais kurzgeschlossen",
"alarm_GeneratorRelayOpenCircuit": "Generatorrelais offen",
"alarm_GeneratorRelayShortCircuit": "Generatorrelais kurzgeschlossen",
"alarm_AbnormalInverter": "Wechselrichter abnormal",
"alarm_ParallelCommunicationAlarm": "Parallelkommunikationsalarm",
"alarm_ParallelModuleMissing": "Parallelmodul fehlt",
"alarm_DuplicateMachineNumbersForParallelModules": "Doppelte Gerätenummern",
"alarm_ParameterConflictInParallelModule": "Parameterkonflikt im Parallelmodul",
"alarm_SystemDerating": "Systemleistung reduziert",
"alarm_PvAccessMethodErrorAlarm": "PV-Zugriffsfehler",
"alarm_ReservedAlarms4": "Reservierter Alarm 4",
"alarm_ReservedAlarms5": "Reservierter Alarm 5",
"alarm_ReverseMeterConnection": "Zähler falsch angeschlossen",
"alarm_InverterSealPulse": "Wechselrichter-Leistungsbegrenzung",
"alarm_AbnormalDieselGeneratorVoltage": "Ungewöhnliche Dieselgenerator-Spannung",
"alarm_AbnormalDieselGeneratorFrequency": "Ungewöhnliche Dieselgenerator-Frequenz",
"alarm_DieselGeneratorVoltageReverseSequence": "Falsche Phasenfolge des Generators",
"alarm_DieselGeneratorVoltageOutOfPhase": "Generator nicht synchronisiert",
"alarm_GeneratorOverload": "Generator überlastet",
"alarm_StringFault": "PV-String-Fehler",
"alarm_PvStringPidQuickConnectAbnormal": "PV-String-Anschluss defekt",
"alarm_DcSpdFunctionAbnormal": "DC-Überspannungsschutz defekt",
"alarm_PvShortCircuited": "PV-String kurzgeschlossen",
"alarm_PvBoostDriverAbnormal": "PV-Boost-Treiber defekt",
"alarm_AcSpdFunctionAbnormal": "AC-Überspannungsschutz defekt",
"alarm_DcFuseBlown": "DC-Sicherung durchgebrannt",
"alarm_DcInputVoltageTooHigh": "DC-Eingangsspannung zu hoch",
"alarm_PvReversed": "PV-Polarität vertauscht",
"alarm_PidFunctionAbnormal": "PID-Schutzfunktion gestört",
"alarm_PvStringDisconnected": "PV-String getrennt",
"alarm_PvStringCurrentUnbalanced": "PV-String Strom unausgeglichen",
"alarm_NoUtilityGrid": "Kein Stromnetz",
"alarm_GridVoltageOutOfRange": "Netzspannung außerhalb des Bereichs",
"alarm_GridFrequencyOutOfRange": "Netzfrequenz außerhalb des Bereichs",
"alarm_Overload": "Überlastung",
"alarm_MeterDisconnected": "Stromzähler getrennt",
"alarm_MeterReverselyConnected": "Zähler falsch angeschlossen",
"alarm_LinePeVoltageAbnormal": "Abnormale PE-Spannung",
"alarm_PhaseSequenceError": "Phasenfolgefehler",
"alarm_FanFailure": "Lüfterausfall",
"alarm_MeterAbnormal": "Störungsanzeige Zähler",
"alarm_OptimizerCommunicationAbnormal": "Kommunikationsstörung Optimierer",
"alarm_OverTemperature": "Überhitzung",
"alarm_OverTemperatureAlarm": "Überhitzungswarnung",
"alarm_NtcTemperatureSensorBroken": "Temperatursensor defekt",
"alarm_SyncSignalAbnormal": "Synchronisationsfehler",
"alarm_GridStartupConditionsNotMet": "Netzstartbedingungen nicht erfüllt",
"alarm_BatteryCommunicationFailure": "Batteriekommunikation fehlgeschlagen",
"alarm_BatteryDisconnected": "Batterie getrennt",
"alarm_BatteryVoltageTooHigh": "Batteriespannung zu hoch",
"alarm_BatteryVoltageTooLow": "Batteriespannung zu niedrig",
"alarm_BatteryReverseConnected": "Batterie falsch angeschlossen",
"alarm_LeadAcidTempSensorDisconnected": "Temperatursensor nicht angeschlossen",
"alarm_BatteryTemperatureOutOfRange": "Batterietemperatur außerhalb des Bereichs",
"alarm_BmsFault": "BMS-Fehler",
"alarm_LithiumBatteryOverload": "Batterie-Überlastung",
"alarm_BmsCommunicationAbnormal": "BMS-Kommunikationsfehler",
"alarm_BatterySpdAbnormal": "Batterie-Überspannungsschutz",
"alarm_OutputDcComponentBiasAbnormal": "DC-Versatz im Ausgang",
"alarm_DcComponentOverHighOutputVoltage": "DC-Komponente zu hohe Ausgangsspannung",
"alarm_OffGridOutputVoltageTooLow": "Netzunabhängige Ausgangsspannung zu niedrig",
"alarm_OffGridOutputVoltageTooHigh": "Netzunabhängige Ausgangsspannung zu hoch",
"alarm_OffGridOutputOverCurrent": "Netzunabhängiger Ausgangsüberstrom",
"alarm_OffGridOutputOverload": "Netzunabhängiger Ausgang überlastet",
"alarm_BalancedCircuitAbnormal": "Phasenausgleich gestört",
"alarm_ExportLimitationFailSafe": "Exportbegrenzung Notaus",
"alarm_DcBiasAbnormal": "DC-Vorspannung abnormal",
"alarm_HighDcComponentOutputCurrent": "Hohe DC-Komponente im Ausgangsstrom",
"alarm_BusVoltageSamplingAbnormal": "Spannungsmessung defekt",
"alarm_RelayFault": "Relaisfehler",
"alarm_BusVoltageAbnormal": "Gleichspannung abnormal",
"alarm_InternalCommunicationFailure": "Interne Kommunikation ausgefallen",
"alarm_TemperatureSensorDisconnected": "Temperatursensor getrennt",
"alarm_IgbtDriveFault": "IGBT-Ansteuerungsfehler",
"alarm_EepromError": "EEPROM-Fehler",
"alarm_AuxiliaryPowerAbnormal": "Hilfsstromversorgung abnormal",
"alarm_DcAcOvercurrentProtection": "Überstromschutz aktiviert",
"alarm_CommunicationProtocolMismatch": "Kommunikationsprotokoll-Fehler",
"alarm_DspComFirmwareMismatch": "Firmware-Inkompatibilität DSP/COM",
"alarm_DspSoftwareHardwareMismatch": "DSP-Software-Hardware-Inkompatibilität",
"alarm_CpldAbnormal": "CPLD-Fehler",
"alarm_RedundancySamplingInconsistent": "Inkonsistente redundante Messungen",
"alarm_PwmPassThroughSignalFailure": "PWM-Signalweg ausgefallen",
"alarm_AfciSelfTestFailure": "AFCI-Selbsttest fehlgeschlagen",
"alarm_PvCurrentSamplingAbnormal": "PV-Strommessung abnormal",
"alarm_AcCurrentSamplingAbnormal": "AC-Strommessung abnormal",
"alarm_BusSoftbootFailure": "DC-Bus-Vorstart fehlgeschlagen",
"alarm_EpoFault": "EPO-Fehler (Notaus)",
"alarm_MonitoringChipBootVerificationFailed": "Überwachungs-Chip Startfehler",
"alarm_BmsCommunicationFailure": "BMS-Kommunikationsfehler",
"alarm_BmsChargeDischargeFailure": "BMS-Lade-/Entladefehler",
"alarm_BatteryVoltageLow": "Batteriespannung zu niedrig",
"alarm_BatteryVoltageHigh": "Batteriespannung zu hoch",
"alarm_BatteryTemperatureAbnormal": "Batterietemperatur ungewöhnlich",
"alarm_BatteryReversed": "Batterie verkehrt herum",
"alarm_BatteryOpenCircuit": "Batteriekreis offen",
"alarm_BatteryOverloadProtection": "Batterieüberlastungsschutz",
"alarm_Bus2VoltageAbnormal": "Bus2-Spannung ungewöhnlich",
"alarm_BatteryChargeOcp": "Batterieladung Überstrom",
"alarm_BatteryDischargeOcp": "Batterieentladung Überstrom",
"alarm_BatterySoftStartFailed": "Batterie-Softstart fehlgeschlagen",
"alarm_EpsOutputShortCircuited": "EPS-Ausgang kurzgeschlossen",
"alarm_OffGridBusVoltageLow": "Netzunabhängige Busspannung zu niedrig",
"alarm_OffGridTerminalVoltageAbnormal": "Abnormale Spannung am Netzausgang",
"alarm_SoftStartFailed": "Sanfter Start fehlgeschlagen",
"alarm_OffGridOutputVoltageAbnormal": "Abnormale Ausgangsspannung im Netzmodus",
"alarm_BalancedCircuitSelfTestFailed": "Ausgleichsschaltungstest fehlgeschlagen",
"alarm_HighDcComponentOutputVoltage": "Hohe Gleichspannungskomponente im Ausgang",
"alarm_OffGridParallelSignalAbnormal": "Parallelsignalstörung",
"alarm_AFCIFault": "Lichtbogenfehler",
"alarm_GFCIHigh": "Erhöhter Fehlerstrom",
"alarm_PVVoltageHigh": "PV-Spannung zu hoch",
"alarm_OffGridBusVoltageTooLow": "Off-Grid-Busspannung zu niedrig",
"Information": "Informationen",
"allInstallations": "Alle Installationen",
"group": "Gruppe",
"groups": "Gruppen",
"requiredOrderNumber": "Pflichtbestellnummer"
}

View File

@ -5,6 +5,9 @@
"customerName": "Customer name",
"english": "English",
"german": "German",
"french": "French",
"italian": "Italian",
"language": "Language",
"installation": "Installation",
"location": "Location",
"log": "Log",
@ -70,5 +73,58 @@
"unableToGrantAccess": "Unable to grant access to: ",
"unableToLoadData": "Unable to load data",
"unableToRevokeAccess": "Unable to revoke access",
"revokedAccessFromUser": "Revoked access from user: "
"revokedAccessFromUser": "Revoked access from user: ",
"Show Errors": "Show Errors",
"Show Warnings": "Show Warnings",
"lastSeen": "Last seen",
"reportTitle": "Weekly Performance Report",
"weeklyInsights": "Weekly Insights",
"weeklySavings": "Your Savings This Week",
"solarEnergyUsed": "Energy Saved",
"solarStayedHome": "solar + battery, not bought from grid",
"daysOfYourUsage": "days of your usage",
"estMoneySaved": "Est. Money Saved",
"atCHFRate": "at 0.39 CHF/kWh avg.",
"solarCoverage": "Self-Sufficiency",
"fromSolarSub": "from solar + battery",
"avgDailyConsumption": "Avg Daily Consumption",
"batteryEfficiency": "Battery Efficiency",
"batteryEffSub": "discharge vs charge",
"weeklySummary": "Weekly Summary",
"metric": "Metric",
"thisWeek": "This Week",
"change": "Change",
"pvProduction": "PV Production",
"consumption": "Consumption",
"gridImport": "Grid Import",
"gridExport": "Grid Export",
"batteryInOut": "Battery Charge / Discharge",
"dailyBreakdown": "Daily Breakdown",
"prevWeek": "(prev week)",
"sendReport": "Send Report",
"generatingReport": "Generating weekly report...",
"reportSentTo": "Report sent to {email}",
"reportSendError": "Failed to send. Please check the email address and try again.",
"ok": "Ok",
"grantedAccessToUser": "Granted access to user {name}",
"proceed": "Proceed",
"firmwareUpdating": "The firmware is getting updated. Please wait...",
"confirmFirmwareUpdate": "Do you really want to update the firmware?",
"batteryServiceStopWarning": "This action requires the battery service to be stopped for around 10-15 minutes.",
"downloadingBatteryLog": "The battery log is getting downloaded. It will be saved in the Downloads folder. Please wait...",
"confirmBatteryLogDownload": "Do you really want to download battery log?",
"downloadBatteryLogFailed": "Download battery log failed, please try again.",
"noReportData": "No report data found.",
"ai_analyzing": "AI is analyzing...",
"ai_show_details": "Show details",
"ai_show_less": "Show less",
"ai_likely_causes": "Likely causes:",
"ai_next_steps": "Suggested next steps:",
"demo_test_button": "Test AI Diagnosis",
"demo_hide_button": "Hide AI Diagnosis Demo",
"demo_panel_title": "AI Diagnosis Demo",
"demo_custom_group": "Custom (may use Mistral AI)",
"demo_custom_option": "Type custom alarm below…",
"demo_custom_placeholder": "e.g. UnknownBatteryFault",
"demo_diagnose_button": "Diagnose"
}

View File

@ -11,7 +11,10 @@
"english": "Anglais",
"error": "Erreur",
"folder": "Dossier",
"french": "Français",
"german": "Allemand",
"italian": "Italien",
"language": "Langue",
"overview": "Aperçu",
"manage": "Gestion des accès",
"configuration": "Configuration",
@ -19,7 +22,6 @@
"apply_changes": "Appliquer",
"delete_user": "Supprimer l'utilisateur",
"installation_name_simple": "Nom de l'installation: ",
"language": "Langue",
"minimum_soc": "Soc minimum",
"calibration_charge_forced": "Charge d'étalonnage forcée",
"grid_set_point": "Point de consigne de grid",
@ -41,7 +43,7 @@
"lastWeek": "La semaine dernière",
"location": "Localité",
"log": "Journal",
"logout": "Fermer las session",
"logout": "Fermer la session",
"makeASelection": "Veuillez faire une sélection à gauche",
"manageAccess": "Gérer l'accès",
"move": "Déplacer",
@ -63,7 +65,7 @@
"status": "Statut",
"live": "Diffusion en direct",
"deleteInstallation": "Supprimer l'installation",
"errorOccured": "Une erreur sest produite",
"errorOccured": "Une erreur s'est produite",
"successfullyUpdated": "Mise à jour réussie",
"grantAccess": "Accorder l'accès",
"UserswithDirectAccess": "Utilisateurs avec accès direct",
@ -83,5 +85,298 @@
"unableToGrantAccess": "Impossible d'accorder l'accès à",
"unableToLoadData": "Impossible de charger les données",
"unableToRevokeAccess": "Impossible de révoquer l'accès",
"revokedAccessFromUser": "Accès révoqué de l'utilisateur"
"revokedAccessFromUser": "Accès révoqué de l'utilisateur",
"Show Errors": "Afficher les erreurs",
"Show Warnings": "Afficher les avertissements",
"lastSeen": "Dernière connexion",
"reportTitle": "Rapport de performance hebdomadaire",
"weeklyInsights": "Aperçus hebdomadaires",
"weeklySavings": "Vos économies cette semaine",
"solarEnergyUsed": "Énergie économisée",
"solarStayedHome": "solaire + batterie, non achetée au réseau",
"daysOfYourUsage": "jours de votre consommation",
"estMoneySaved": "Économies estimées",
"atCHFRate": "à 0,39 CHF/kWh moy.",
"solarCoverage": "Autosuffisance",
"fromSolarSub": "du solaire + batterie",
"avgDailyConsumption": "Conso. quotidienne moy.",
"batteryEfficiency": "Efficacité de la batterie",
"batteryEffSub": "décharge vs charge",
"weeklySummary": "Résumé hebdomadaire",
"metric": "Métrique",
"thisWeek": "Cette semaine",
"change": "Variation",
"pvProduction": "Production PV",
"consumption": "Consommation",
"gridImport": "Importation réseau",
"gridExport": "Exportation réseau",
"batteryInOut": "Batterie Charge / Décharge",
"dailyBreakdown": "Répartition quotidienne",
"prevWeek": "(semaine précédente)",
"sendReport": "Envoyer le rapport",
"generatingReport": "Génération du rapport hebdomadaire...",
"reportSentTo": "Rapport envoyé à {email}",
"reportSendError": "Échec de l'envoi. Veuillez vérifier l'adresse e-mail et réessayer.",
"ok": "Ok",
"grantedAccessToUser": "Accès accordé à l'utilisateur {name}",
"proceed": "Continuer",
"firmwareUpdating": "Le firmware est en cours de mise à jour. Veuillez patienter...",
"confirmFirmwareUpdate": "Voulez-vous vraiment mettre à jour le firmware?",
"batteryServiceStopWarning": "Cette action nécessite l'arrêt du service batterie pendant environ 10-15 minutes.",
"downloadingBatteryLog": "Le journal de la batterie est en cours de téléchargement. Il sera enregistré dans le dossier Téléchargements. Veuillez patienter...",
"confirmBatteryLogDownload": "Voulez-vous vraiment télécharger le journal de la batterie?",
"downloadBatteryLogFailed": "Échec du téléchargement du journal de la batterie, veuillez réessayer.",
"noReportData": "Aucune donnée de rapport trouvée.",
"ai_analyzing": "L'IA analyse...",
"ai_show_details": "Afficher les détails",
"ai_show_less": "Afficher moins",
"ai_likely_causes": "Causes probables :",
"ai_next_steps": "Prochaines étapes suggérées :",
"demo_test_button": "Tester le diagnostic IA",
"demo_hide_button": "Masquer la démo de diagnostic IA",
"demo_panel_title": "Démo de diagnostic IA",
"demo_custom_group": "Personnalisé (peut utiliser Mistral IA)",
"demo_custom_option": "Saisir une alarme personnalisée…",
"demo_custom_placeholder": "ex. UnknownBatteryFault",
"demo_diagnose_button": "Diagnostiquer",
"alarm_AbnormalGridVoltage": "Tension réseau anormale",
"alarm_AbnormalGridFrequency": "Fréquence réseau anormale",
"alarm_InvertedSequenceOfGridVoltage": "Séquence de tension inversée",
"alarm_GridVoltagePhaseLoss": "Perte de phase réseau",
"alarm_AbnormalGridCurrent": "Courant réseau anormal",
"alarm_AbnormalOutputVoltage": "Tension de sortie anormale",
"alarm_AbnormalOutputFrequency": "Fréquence de sortie anormale",
"alarm_AbnormalNullLine": "Ligne neutre anormale",
"alarm_AbnormalOffGridOutputVoltage": "Tension de sortie hors réseau anormale",
"alarm_ExcessivelyHighAmbientTemperature": "Température ambiante trop élevée",
"alarm_ExcessiveRadiatorTemperature": "Température excessive du radiateur",
"alarm_PcbOvertemperature": "Température excessive PCB",
"alarm_DcConverterOvertemperature": "Température excessive convertisseur DC",
"alarm_InverterOvertemperatureAlarm": "Alarme température onduleur",
"alarm_InverterOvertemperature": "Température onduleur excessive",
"alarm_DcConverterOvertemperatureAlarm": "Alarme surchauffe convertisseur DC",
"alarm_InsulationFault": "Défaut d'isolation",
"alarm_LeakageProtectionFault": "Défaut protection fuite",
"alarm_AbnormalLeakageSelfCheck": "Auto-test fuite anormale",
"alarm_PoorGrounding": "Mise à la terre insuffisante",
"alarm_FanFault": "Défaut du ventilateur",
"alarm_AuxiliaryPowerFault": "Défaut d'alimentation auxiliaire",
"alarm_ModelCapacityFault": "Défaut de configuration",
"alarm_AbnormalLightningArrester": "Paratonnerre défectueux",
"alarm_IslandProtection": "Protection d'îlotage",
"alarm_Battery1NotConnected": "Batterie 1 non connectée",
"alarm_Battery1Overvoltage": "Tension batterie 1 trop élevée",
"alarm_Battery1Undervoltage": "Tension batterie 1 trop basse",
"alarm_Battery1DischargeEnd": "Fin de décharge batterie 1",
"alarm_Battery1Inverted": "Polarité batterie 1 inversée",
"alarm_Battery1OverloadTimeout": "Dépassement de charge Batterie 1",
"alarm_Battery1SoftStartFailure": "Échec démarrage Batterie 1",
"alarm_Battery1PowerTubeFault": "Défaut électronique Batterie 1",
"alarm_Battery1InsufficientPower": "Puissance insuffisante Batterie 1",
"alarm_Battery1BackupProhibited": "Sauvegarde interdite Batterie 1",
"alarm_Battery2NotConnected": "Batterie 2 non connectée",
"alarm_Battery2Overvoltage": "Tension batterie 2 élevée",
"alarm_Battery2Undervoltage": "Tension batterie 2 basse",
"alarm_Battery2DischargeEnd": "Fin décharge batterie 2",
"alarm_Battery2Inverted": "Polarité batterie 2 inversée",
"alarm_Battery2OverloadTimeout": "Dépassement de charge Batterie 2",
"alarm_Battery2SoftStartFailure": "Échec démarrage Batterie 2",
"alarm_Battery2PowerTubeFault": "Défaut électronique Batterie 2",
"alarm_Battery2InsufficientPower": "Puissance insuffisante Batterie 2",
"alarm_Battery2BackupProhibited": "Sauvegarde interdite Batterie 2",
"alarm_LithiumBattery1ChargeForbidden": "Charge batterie lithium 1 interdite",
"alarm_LithiumBattery1DischargeForbidden": "Décharge batterie lithium 1 interdite",
"alarm_LithiumBattery2ChargeForbidden": "Charge batterie lithium 2 interdite",
"alarm_LithiumBattery2DischargeForbidden": "Décharge batterie lithium 2 interdite",
"alarm_LithiumBattery1Full": "Batterie lithium 1 pleine",
"alarm_LithiumBattery1DischargeEnd": "Fin de décharge batterie lithium 1",
"alarm_LithiumBattery2Full": "Batterie lithium 2 pleine",
"alarm_LithiumBattery2DischargeEnd": "Fin de décharge batterie lithium 2",
"alarm_LeadBatteryTemperatureAbnormality": "Température anormale batterie plomb",
"alarm_BatteryAccessMethodError": "Erreur de méthode d'accès batterie",
"alarm_Pv1NotAccessed": "Chaîne PV1 non accessible",
"alarm_Pv1Overvoltage": "Survoltage PV1",
"alarm_AbnormalPv1CurrentSharing": "Partage de courant PV1 anormal",
"alarm_Pv1PowerTubeFault": "Défaut du tube de puissance PV1",
"alarm_Pv1SoftStartFailure": "Échec de démarrage doux PV1",
"alarm_Pv1OverloadTimeout": "Dépassement de charge PV1",
"alarm_Pv1InsufficientPower": "Puissance PV1 insuffisante",
"alarm_Photovoltaic1Overcurrent": "Surintensité PV1",
"alarm_Pv2NotAccessed": "Chaîne PV2 inaccessible",
"alarm_Pv2Overvoltage": "Survoltage PV2",
"alarm_AbnormalPv2CurrentSharing": "Partage de courant anormal PV2",
"alarm_Pv2PowerTubeFault": "Défaillance du tube de puissance PV2",
"alarm_Pv2SoftStartFailure": "Échec de démarrage progressif PV2",
"alarm_Pv2OverloadTimeout": "Dépassement de charge PV2",
"alarm_Pv2InsufficientPower": "Puissance insuffisante PV2",
"alarm_Pv3NotConnected": "PV3 non connecté",
"alarm_Pv3Overvoltage": "Survoltage PV3",
"alarm_Pv3AverageCurrentAnomaly": "Anomalie courant PV3",
"alarm_Pv3PowerTubeFailure": "Défaillance tube PV3",
"alarm_Pv3SoftStartFailure": "Échec démarrage PV3",
"alarm_Pv3OverloadTimeout": "Dépassement de charge PV3",
"alarm_Pv3ReverseConnection": "Connexion inversée PV3",
"alarm_Pv4NotConnected": "Chaîne PV4 non connectée",
"alarm_Pv4Overvoltage": "Survoltage PV4",
"alarm_Pv4AverageCurrentAnomaly": "Anomalie de courant PV4",
"alarm_Pv4PowerTubeFailure": "Défaillance du tube de puissance PV4",
"alarm_Pv4SoftStartFailure": "Échec du démarrage progressif PV4",
"alarm_Pv4OverloadTimeout": "Dépassement de charge PV4",
"alarm_Pv4ReverseConnection": "Connexion inversée PV4",
"alarm_InsufficientPhotovoltaicPower": "Puissance photovoltaïque insuffisante",
"alarm_DcBusOvervoltage": "Tension DC trop élevée",
"alarm_DcBusUndervoltage": "Tension DC trop basse",
"alarm_DcBusVoltageUnbalance": "Déséquilibre tension DC",
"alarm_BusSlowOvervoltage": "Tension DC lente excessive",
"alarm_HardwareBusOvervoltage": "Tension DC critique",
"alarm_BusSoftStartFailure": "Échec démarrage progressif",
"alarm_InverterPowerTubeFault": "Défaut tube de puissance",
"alarm_HardwareOvercurrent": "Surintensité matérielle",
"alarm_DcConverterOvervoltage": "Survoltage convertisseur DC",
"alarm_DcConverterHardwareOvervoltage": "Survoltage matériel convertisseur DC",
"alarm_DcConverterOvercurrent": "Surintensité convertisseur CC",
"alarm_DcConverterHardwareOvercurrent": "Surintensité matérielle convertisseur CC",
"alarm_DcConverterResonatorOvercurrent": "Surintensité résonateur convertisseur CC",
"alarm_SystemOutputOverload": "Surcharge de sortie système",
"alarm_InverterOverload": "Surcharge onduleur",
"alarm_InverterOverloadTimeout": "Dépassement de charge de l'onduleur",
"alarm_LoadPowerOverload": "Surcharge de puissance de charge",
"alarm_BalancedCircuitOverloadTimeout": "Dépassement de charge du circuit équilibré",
"alarm_InverterSoftStartFailure": "Échec de démarrage progressif de l'onduleur",
"alarm_Dsp1ParameterSettingFault": "Défaillance de paramétrage DSP 1",
"alarm_Dsp2ParameterSettingFault": "Paramètre DSP2 incorrect",
"alarm_DspVersionCompatibilityFault": "Incompatibilité version DSP",
"alarm_CpldVersionCompatibilityFault": "Incompatibilité version CPLD",
"alarm_CpldCommunicationFault": "Échec communication CPLD",
"alarm_DspCommunicationFault": "Échec communication DSP",
"alarm_OutputVoltageDcOverlimit": "Tension de sortie DC excessive",
"alarm_OutputCurrentDcOverlimit": "Courant de sortie DC excessif",
"alarm_RelaySelfCheckFails": "Auto-test relais échoué",
"alarm_InverterRelayOpen": "Relais de l'onduleur ouvert",
"alarm_InverterRelayShortCircuit": "Relais de l'onduleur en court-circuit",
"alarm_OpenCircuitOfPowerGridRelay": "Relais du réseau ouvert",
"alarm_ShortCircuitOfPowerGridRelay": "Court-circuit du relais réseau",
"alarm_GeneratorRelayOpenCircuit": "Relais du générateur ouvert",
"alarm_GeneratorRelayShortCircuit": "Court-circuit du relais générateur",
"alarm_AbnormalInverter": "Onduleur anormal",
"alarm_ParallelCommunicationAlarm": "Alarme de communication parallèle",
"alarm_ParallelModuleMissing": "Module parallèle manquant",
"alarm_DuplicateMachineNumbersForParallelModules": "Numéros de machine en double",
"alarm_ParameterConflictInParallelModule": "Conflit de paramètres parallèle",
"alarm_SystemDerating": "Réduction de puissance du système",
"alarm_PvAccessMethodErrorAlarm": "Erreur méthode d'accès PV",
"alarm_ReservedAlarms4": "Alarme réservée 4",
"alarm_ReservedAlarms5": "Alarme réservée 5",
"alarm_ReverseMeterConnection": "Connexion du compteur inversée",
"alarm_InverterSealPulse": "Impulsion de scellement de l'onduleur",
"alarm_AbnormalDieselGeneratorVoltage": "Tension anormale du générateur diesel",
"alarm_AbnormalDieselGeneratorFrequency": "Fréquence anormale du générateur diesel",
"alarm_DieselGeneratorVoltageReverseSequence": "Séquence de phase inversée du générateur",
"alarm_DieselGeneratorVoltageOutOfPhase": "Déphasage du générateur",
"alarm_GeneratorOverload": "Surcharge du générateur",
"alarm_StringFault": "Défaut de chaîne",
"alarm_PvStringPidQuickConnectAbnormal": "Connexion rapide anormale",
"alarm_DcSpdFunctionAbnormal": "Problème de protection DC",
"alarm_PvShortCircuited": "Court-circuit PV",
"alarm_PvBoostDriverAbnormal": "Problème de convertisseur",
"alarm_AcSpdFunctionAbnormal": "Problème de protection contre les surtensions AC",
"alarm_DcFuseBlown": "Fusible DC grillé",
"alarm_DcInputVoltageTooHigh": "Tension DC d'entrée trop élevée",
"alarm_PvReversed": "Polarité PV inversée",
"alarm_PidFunctionAbnormal": "Problème de fonction PID",
"alarm_PvStringDisconnected": "Chaîne PV déconnectée",
"alarm_PvStringCurrentUnbalanced": "Déséquilibre de courant PV",
"alarm_NoUtilityGrid": "Réseau électrique absent",
"alarm_GridVoltageOutOfRange": "Tension réseau hors plage",
"alarm_GridFrequencyOutOfRange": "Fréquence réseau hors plage",
"alarm_Overload": "Surcharge",
"alarm_MeterDisconnected": "Compteur déconnecté",
"alarm_MeterReverselyConnected": "Compteur inversé",
"alarm_LinePeVoltageAbnormal": "Tension anormale",
"alarm_PhaseSequenceError": "Séquence de phase erronée",
"alarm_FanFailure": "Défaillance du ventilateur",
"alarm_MeterAbnormal": "Compteur anormal",
"alarm_OptimizerCommunicationAbnormal": "Communication optimiseur anormale",
"alarm_OverTemperature": "Température excessive",
"alarm_OverTemperatureAlarm": "Alarme température élevée",
"alarm_NtcTemperatureSensorBroken": "Capteur de température défectueux",
"alarm_SyncSignalAbnormal": "Signal de synchronisation anormal",
"alarm_GridStartupConditionsNotMet": "Conditions de démarrage réseau non remplies",
"alarm_BatteryCommunicationFailure": "Échec de communication batterie",
"alarm_BatteryDisconnected": "Batterie déconnectée",
"alarm_BatteryVoltageTooHigh": "Tension batterie trop élevée",
"alarm_BatteryVoltageTooLow": "Tension batterie trop basse",
"alarm_BatteryReverseConnected": "Batterie branchée à l'envers",
"alarm_LeadAcidTempSensorDisconnected": "Capteur température batterie plomb désactivé",
"alarm_BatteryTemperatureOutOfRange": "Température batterie hors plage",
"alarm_BmsFault": "Défaillance BMS",
"alarm_LithiumBatteryOverload": "Surcharge batterie lithium",
"alarm_BmsCommunicationAbnormal": "Communication BMS anormale",
"alarm_BatterySpdAbnormal": "Défaillance SPD batterie",
"alarm_OutputDcComponentBiasAbnormal": "Biais DC de sortie anormal",
"alarm_DcComponentOverHighOutputVoltage": "Tension de sortie trop élevée",
"alarm_OffGridOutputVoltageTooLow": "Tension de sortie hors réseau trop basse",
"alarm_OffGridOutputVoltageTooHigh": "Tension de sortie hors réseau trop élevée",
"alarm_OffGridOutputOverCurrent": "Courant de sortie hors réseau trop élevé",
"alarm_OffGridOutputOverload": "Surcharge sortie hors réseau",
"alarm_BalancedCircuitAbnormal": "Circuit équilibré anormal",
"alarm_ExportLimitationFailSafe": "Sécurité limite d'exportation",
"alarm_DcBiasAbnormal": "Biais DC anormal",
"alarm_HighDcComponentOutputCurrent": "Composante DC élevée courant de sortie",
"alarm_BusVoltageSamplingAbnormal": "Tension d'alimentation anormale",
"alarm_RelayFault": "Défaillance du relais",
"alarm_BusVoltageAbnormal": "Tension d'alimentation anormale",
"alarm_InternalCommunicationFailure": "Échec de communication interne",
"alarm_TemperatureSensorDisconnected": "Capteur de température déconnecté",
"alarm_IgbtDriveFault": "Défaillance de l'IGBT",
"alarm_EepromError": "Erreur EEPROM",
"alarm_AuxiliaryPowerAbnormal": "Alimentation auxiliaire anormale",
"alarm_DcAcOvercurrentProtection": "Protection contre les surintensités",
"alarm_CommunicationProtocolMismatch": "Incompatibilité de protocole",
"alarm_DspComFirmwareMismatch": "Incompatibilité firmware DSP/COM",
"alarm_DspSoftwareHardwareMismatch": "Incompatibilité logiciel DSP/matériel",
"alarm_CpldAbnormal": "CPLD anormal",
"alarm_RedundancySamplingInconsistent": "Échantillonnage redondant incohérent",
"alarm_PwmPassThroughSignalFailure": "Échec signal PWM",
"alarm_AfciSelfTestFailure": "Échec auto-test AFCI",
"alarm_PvCurrentSamplingAbnormal": "Mesure PV anormale",
"alarm_AcCurrentSamplingAbnormal": "Mesure AC anormale",
"alarm_BusSoftbootFailure": "Échec démarrage DC",
"alarm_EpoFault": "Défaillance EPO",
"alarm_MonitoringChipBootVerificationFailed": "Échec vérification démarrage",
"alarm_BmsCommunicationFailure": "Échec communication BMS",
"alarm_BmsChargeDischargeFailure": "Échec charge/décharge BMS",
"alarm_BatteryVoltageLow": "Tension batterie faible",
"alarm_BatteryVoltageHigh": "Tension batterie élevée",
"alarm_BatteryTemperatureAbnormal": "Température anormale de la batterie",
"alarm_BatteryReversed": "Batterie inversée",
"alarm_BatteryOpenCircuit": "Circuit batterie ouvert",
"alarm_BatteryOverloadProtection": "Protection contre la surcharge",
"alarm_Bus2VoltageAbnormal": "Tension anormale Bus2",
"alarm_BatteryChargeOcp": "Surintensité charge batterie",
"alarm_BatteryDischargeOcp": "Surintensité décharge batterie",
"alarm_BatterySoftStartFailed": "Démarrage en douceur échoué",
"alarm_EpsOutputShortCircuited": "Circuit de secours en court-circuit",
"alarm_OffGridBusVoltageLow": "Tension bus hors réseau basse",
"alarm_OffGridTerminalVoltageAbnormal": "Tension anormale terminal hors réseau",
"alarm_SoftStartFailed": "Démarrage progressif échoué",
"alarm_OffGridOutputVoltageAbnormal": "Tension de sortie hors réseau anormale",
"alarm_BalancedCircuitSelfTestFailed": "Autotest circuit équilibré échoué",
"alarm_HighDcComponentOutputVoltage": "Tension de sortie à composante CC élevée",
"alarm_OffGridParallelSignalAbnormal": "Signal parallèle hors réseau anormal",
"alarm_AFCIFault": "Défaillance AFCI",
"alarm_GFCIHigh": "Courant de défaut élevé",
"alarm_PVVoltageHigh": "Tension PV élevée",
"alarm_OffGridBusVoltageTooLow": "Tension du bus hors réseau trop faible",
"Information": "Informations",
"allInstallations": "Toutes les installations",
"group": "Groupe",
"groups": "Groupes",
"requiredOrderNumber": "Numéro de commande requis",
"addNewChild": "Ajouter un sous-élément",
"addNewDialogButton": "Ajouter un bouton de dialogue",
"groupTabs": "Groupes",
"groupTree": "Arborescence de groupes",
"installationTabs": "Installations",
"navigationTabs": "Navigation"
}

View File

@ -0,0 +1,382 @@
{
"allInstallations": "Tutte le installazioni",
"applyChanges": "Applica modifiche",
"country": "Paese",
"customerName": "Nome cliente",
"english": "Inglese",
"german": "Tedesco",
"french": "Francese",
"italian": "Italiano",
"language": "Lingua",
"installation": "Installazione",
"location": "Posizione",
"log": "Registro",
"orderNumbers": "Numeri d'ordine",
"region": "Regione",
"search": "Cerca",
"users": "Utenti",
"logout": "Disconnetti",
"updatedSuccessfully": "Aggiornamento riuscito",
"groups": "Gruppi",
"group": "Gruppo",
"folder": "Cartella",
"updateFolderErrorMessage": "Impossibile aggiornare la cartella, si è verificato un errore",
"Information": "Informazioni",
"addNewChild": "Aggiungi nuovo figlio",
"addNewDialogButton": "Aggiungi nuovo pulsante di dialogo",
"addUser": "Crea utente",
"createNewFolder": "Crea nuova cartella",
"createNewUser": "Crea nuovo utente",
"email": "Email",
"error": "Errore",
"groupTabs": "Schede gruppo",
"groupTree": "Albero gruppo",
"information": "Informazioni",
"inheritedAccess": "Accesso ereditato da",
"installationTabs": "Schede installazione",
"installations": "Installazioni",
"lastWeek": "Settimana scorsa",
"makeASelection": "Effettuare una selezione a sinistra",
"manageAccess": "Gestisci accesso",
"move": "Sposta",
"moveTo": "Sposta in",
"moveTree": "Sposta albero",
"name": "Nome",
"navigationTabs": "Schede di navigazione",
"requiredLocation": "La posizione è obbligatoria",
"requiredName": "Il nome è obbligatorio",
"requiredRegion": "La regione è obbligatoria",
"requiredOrderNumber": "Numero d'ordine obbligatorio",
"submit": "Invia",
"user": "Utente",
"userTabs": "Schede utente",
"status": "Stato",
"live": "Vista in diretta",
"deleteInstallation": "Elimina installazione",
"errorOccured": "Si è verificato un errore",
"successfullyUpdated": "Aggiornamento riuscito",
"grantAccess": "Concedi accesso",
"UserswithDirectAccess": "Utenti con accesso diretto",
"UserswithInheritedAccess": "Utenti con accesso ereditato",
"noerrors": "Non ci sono errori",
"nowarnings": "Non ci sono avvisi",
"noUsersWithDirectAccessToThis": "Nessun utente con accesso diretto a questo",
"selectUsers": "Seleziona utenti",
"cancel": "Annulla",
"addNewFolder": "Aggiungi nuova cartella",
"addNewInstallation": "Aggiungi nuova installazione",
"deleteFolder": "Elimina cartella",
"grantAccessToFolders": "Concedi accesso alle cartelle",
"grantAccessToInstallations": "Concedi accesso alle installazioni",
"cannotloadloggingdata": "Impossibile caricare i dati di registro",
"grantedAccessToUsers": "Accesso concesso agli utenti: ",
"unableToGrantAccess": "Impossibile concedere l'accesso a: ",
"unableToLoadData": "Impossibile caricare i dati",
"unableToRevokeAccess": "Impossibile revocare l'accesso",
"revokedAccessFromUser": "Accesso revocato all'utente: ",
"alarms": "Allarmi",
"overview": "Panoramica",
"manage": "Gestione accessi",
"configuration": "Configurazione",
"installation_name_simple": "Nome installazione: ",
"installation_name": "Nome installazione",
"minimum_soc": "SoC minimo",
"calibration_charge_forced": "Carica di calibrazione forzata",
"grid_set_point": "Punto di riferimento rete",
"Installed_Power_DC1010": "Potenza installata DC1010",
"Maximum_Discharge_Power": "Potenza massima di scarica",
"Number_of_Batteries": "Numero di batterie",
"24_hours": "24 ore",
"lastweek": "Settimana scorsa",
"lastmonth": "Mese scorso",
"apply_changes": "Applica modifiche",
"delete_user": "Elimina utente",
"battery_temperature": "Temperatura batteria",
"pv_production": "Produzione fotovoltaica",
"grid_power": "Potenza di rete",
"battery_power": "Potenza batteria",
"dc_voltage": "Tensione bus DC",
"battery_soc": "Stato di carica (SOC)",
"Show Errors": "Mostra errori",
"Show Warnings": "Mostra avvisi",
"lastSeen": "Ultima visualizzazione",
"reportTitle": "Rapporto settimanale sulle prestazioni",
"weeklyInsights": "Approfondimenti settimanali",
"weeklySavings": "I tuoi risparmi questa settimana",
"solarEnergyUsed": "Energia risparmiata",
"solarStayedHome": "solare + batteria, non acquistata dalla rete",
"daysOfYourUsage": "giorni del tuo consumo",
"estMoneySaved": "Risparmio stimato",
"atCHFRate": "a 0,39 CHF/kWh media",
"solarCoverage": "Autosufficienza",
"fromSolarSub": "da solare + batteria",
"avgDailyConsumption": "Consumo medio giornaliero",
"batteryEfficiency": "Efficienza della batteria",
"batteryEffSub": "scarica vs carica",
"weeklySummary": "Riepilogo settimanale",
"metric": "Metrica",
"thisWeek": "Questa settimana",
"change": "Variazione",
"pvProduction": "Produzione FV",
"consumption": "Consumo",
"gridImport": "Importazione rete",
"gridExport": "Esportazione rete",
"batteryInOut": "Batteria Carica / Scarica",
"dailyBreakdown": "Ripartizione giornaliera",
"prevWeek": "(settimana precedente)",
"sendReport": "Invia rapporto",
"generatingReport": "Generazione del rapporto settimanale...",
"reportSentTo": "Rapporto inviato a {email}",
"reportSendError": "Invio fallito. Verificare l'indirizzo e-mail e riprovare.",
"ok": "Ok",
"grantedAccessToUser": "Accesso concesso all'utente {name}",
"proceed": "Procedi",
"firmwareUpdating": "Il firmware è in fase di aggiornamento. Attendere prego...",
"confirmFirmwareUpdate": "Vuoi davvero aggiornare il firmware?",
"batteryServiceStopWarning": "Questa azione richiede l'interruzione del servizio batteria per circa 10-15 minuti.",
"downloadingBatteryLog": "Il registro della batteria è in fase di download. Verrà salvato nella cartella Download. Attendere prego...",
"confirmBatteryLogDownload": "Vuoi davvero scaricare il registro della batteria?",
"downloadBatteryLogFailed": "Download del registro della batteria fallito, riprovare.",
"noReportData": "Nessun dato del rapporto trovato.",
"ai_analyzing": "L'IA sta analizzando...",
"ai_show_details": "Mostra dettagli",
"ai_show_less": "Mostra meno",
"ai_likely_causes": "Cause probabili:",
"ai_next_steps": "Passi successivi suggeriti:",
"demo_test_button": "Testa diagnosi IA",
"demo_hide_button": "Nascondi demo diagnosi IA",
"demo_panel_title": "Demo diagnosi IA",
"demo_custom_group": "Personalizzato (potrebbe usare Mistral IA)",
"demo_custom_option": "Inserisci allarme personalizzato…",
"demo_custom_placeholder": "es. UnknownBatteryFault",
"demo_diagnose_button": "Diagnostica",
"alarm_AbnormalGridVoltage": "Tensione di rete anomala",
"alarm_AbnormalGridFrequency": "Frequenza di rete anomala",
"alarm_InvertedSequenceOfGridVoltage": "Sequenza di fase invertita",
"alarm_GridVoltagePhaseLoss": "Fase di rete mancante",
"alarm_AbnormalGridCurrent": "Corrente di rete anomala",
"alarm_AbnormalOutputVoltage": "Tensione di uscita anomala",
"alarm_AbnormalOutputFrequency": "Frequenza di uscita anomala",
"alarm_AbnormalNullLine": "Linea neutra anomala",
"alarm_AbnormalOffGridOutputVoltage": "Tensione di uscita off-grid anomala",
"alarm_ExcessivelyHighAmbientTemperature": "Temperatura ambiente eccessivamente alta",
"alarm_ExcessiveRadiatorTemperature": "Temperatura radiatore eccessiva",
"alarm_PcbOvertemperature": "Temperatura PCB eccessiva",
"alarm_DcConverterOvertemperature": "Temperatura convertitore DC eccessiva",
"alarm_InverterOvertemperatureAlarm": "Allarme temperatura inverter elevata",
"alarm_InverterOvertemperature": "Temperatura inverter eccessiva",
"alarm_DcConverterOvertemperatureAlarm": "Allarme sovratemperatura convertitore DC",
"alarm_InsulationFault": "Guasto isolamento",
"alarm_LeakageProtectionFault": "Guasto protezione dispersione",
"alarm_AbnormalLeakageSelfCheck": "Autocontrollo dispersione anomalo",
"alarm_PoorGrounding": "Messa a terra insufficiente",
"alarm_FanFault": "Guasto Ventola",
"alarm_AuxiliaryPowerFault": "Guasto Alimentazione Ausiliaria",
"alarm_ModelCapacityFault": "Guasto Configurazione Modello",
"alarm_AbnormalLightningArrester": "Parasurtense Anomalo",
"alarm_IslandProtection": "Protezione Isola",
"alarm_Battery1NotConnected": "Batteria 1 non collegata",
"alarm_Battery1Overvoltage": "Batteria 1 sovratensione",
"alarm_Battery1Undervoltage": "Batteria 1 sottotensione",
"alarm_Battery1DischargeEnd": "Batteria 1 scarica",
"alarm_Battery1Inverted": "Batteria 1 polarità invertita",
"alarm_Battery1OverloadTimeout": "Timeout sovraccarico batteria 1",
"alarm_Battery1SoftStartFailure": "Avvio morbido fallito batteria 1",
"alarm_Battery1PowerTubeFault": "Guasto modulo potenza batteria 1",
"alarm_Battery1InsufficientPower": "Potenza insufficiente batteria 1",
"alarm_Battery1BackupProhibited": "Backup vietato batteria 1",
"alarm_Battery2NotConnected": "Batteria 2 non collegata",
"alarm_Battery2Overvoltage": "Sovratensione batteria 2",
"alarm_Battery2Undervoltage": "Sottotensione batteria 2",
"alarm_Battery2DischargeEnd": "Fine scarica batteria 2",
"alarm_Battery2Inverted": "Polarità invertita batteria 2",
"alarm_Battery2OverloadTimeout": "Timeout sovraccarico Batteria 2",
"alarm_Battery2SoftStartFailure": "Avvio morbido fallito Batteria 2",
"alarm_Battery2PowerTubeFault": "Guasto modulo potenza Batteria 2",
"alarm_Battery2InsufficientPower": "Potenza insufficiente Batteria 2",
"alarm_Battery2BackupProhibited": "Backup vietato Batteria 2",
"alarm_LithiumBattery1ChargeForbidden": "Carica Batteria Litio 1 Bloccata",
"alarm_LithiumBattery1DischargeForbidden": "Scarica Batteria Litio 1 Bloccata",
"alarm_LithiumBattery2ChargeForbidden": "Carica Batteria Litio 2 Bloccata",
"alarm_LithiumBattery2DischargeForbidden": "Scarica Batteria Litio 2 Bloccata",
"alarm_LithiumBattery1Full": "Batteria Litio 1 Piena",
"alarm_LithiumBattery1DischargeEnd": "Fine scarica batteria litio 1",
"alarm_LithiumBattery2Full": "Batteria litio 2 piena",
"alarm_LithiumBattery2DischargeEnd": "Fine scarica batteria litio 2",
"alarm_LeadBatteryTemperatureAbnormality": "Temperatura batteria piombo anomala",
"alarm_BatteryAccessMethodError": "Errore metodo accesso batteria",
"alarm_Pv1NotAccessed": "PV1 non accessibile",
"alarm_Pv1Overvoltage": "Sovratensione PV1",
"alarm_AbnormalPv1CurrentSharing": "Corrente PV1 anomala",
"alarm_Pv1PowerTubeFault": "Guasto tubo di potenza PV1",
"alarm_Pv1SoftStartFailure": "Avvio morbido PV1 fallito",
"alarm_Pv1OverloadTimeout": "Sovraccarico PV1",
"alarm_Pv1InsufficientPower": "Bassa potenza PV1",
"alarm_Photovoltaic1Overcurrent": "Sovracorrente PV1",
"alarm_Pv2NotAccessed": "PV2 non rilevato",
"alarm_Pv2Overvoltage": "Sovratensione PV2",
"alarm_AbnormalPv2CurrentSharing": "Condivisione Corrente PV2 Anomala",
"alarm_Pv2PowerTubeFault": "Guasto Tubo di Potenza PV2",
"alarm_Pv2SoftStartFailure": "Avvio Morbido PV2 Fallito",
"alarm_Pv2OverloadTimeout": "Sovraccarico PV2 Timeout",
"alarm_Pv2InsufficientPower": "Potenza Insufficiente PV2",
"alarm_Pv3NotConnected": "PV3 non connesso",
"alarm_Pv3Overvoltage": "Sovratensione PV3",
"alarm_Pv3AverageCurrentAnomaly": "Corrente PV3 anomala",
"alarm_Pv3PowerTubeFailure": "Guasto tubo di potenza PV3",
"alarm_Pv3SoftStartFailure": "Avvio morbido PV3 fallito",
"alarm_Pv3OverloadTimeout": "Sovraccarico PV3",
"alarm_Pv3ReverseConnection": "Connessione Inversa PV3",
"alarm_Pv4NotConnected": "PV4 Non Collegato",
"alarm_Pv4Overvoltage": "Sovratensione PV4",
"alarm_Pv4AverageCurrentAnomaly": "Anomalia Corrente PV4",
"alarm_Pv4PowerTubeFailure": "Guasto Tubo di Potenza PV4",
"alarm_Pv4SoftStartFailure": "Avvio Morbido PV4 Fallito",
"alarm_Pv4OverloadTimeout": "Sovraccarico PV4",
"alarm_Pv4ReverseConnection": "Connessione Inversa PV4",
"alarm_InsufficientPhotovoltaicPower": "Potenza Fotovoltaica Insufficiente",
"alarm_DcBusOvervoltage": "Sovratensione Bus DC",
"alarm_DcBusUndervoltage": "Sottotensione Bus DC",
"alarm_DcBusVoltageUnbalance": "Squilibrio Tensione Bus DC",
"alarm_BusSlowOvervoltage": "Sovratensione Lenta Bus",
"alarm_HardwareBusOvervoltage": "Sovratensione Critica Bus",
"alarm_BusSoftStartFailure": "Avvio morbido fallito",
"alarm_InverterPowerTubeFault": "Guasto al modulo di potenza",
"alarm_HardwareOvercurrent": "Sovracorrente hardware",
"alarm_DcConverterOvervoltage": "Sovratensione convertitore DC",
"alarm_DcConverterHardwareOvervoltage": "Sovratensione hardware convertitore",
"alarm_DcConverterOvercurrent": "Sovraccarico convertitore DC",
"alarm_DcConverterHardwareOvercurrent": "Sovraccarico hardware convertitore DC",
"alarm_DcConverterResonatorOvercurrent": "Sovraccarico risonatore convertitore DC",
"alarm_SystemOutputOverload": "Sovraccarico uscita sistema",
"alarm_InverterOverload": "Sovraccarico inverter",
"alarm_InverterOverloadTimeout": "Sovraccarico Inverter",
"alarm_LoadPowerOverload": "Sovraccarico Carico",
"alarm_BalancedCircuitOverloadTimeout": "Sovraccarico Circuito Bilanciato",
"alarm_InverterSoftStartFailure": "Avvio Inverter Fallito",
"alarm_Dsp1ParameterSettingFault": "Parametri DSP Errati",
"alarm_Dsp2ParameterSettingFault": "Errore configurazione parametri DSP 2",
"alarm_DspVersionCompatibilityFault": "Errore compatibilità versione DSP",
"alarm_CpldVersionCompatibilityFault": "Errore compatibilità versione CPLD",
"alarm_CpldCommunicationFault": "Errore comunicazione CPLD",
"alarm_DspCommunicationFault": "Errore comunicazione DSP",
"alarm_OutputVoltageDcOverlimit": "Tensione DC in uscita eccessiva",
"alarm_OutputCurrentDcOverlimit": "Corrente DC in uscita eccessiva",
"alarm_RelaySelfCheckFails": "Autotest relè fallito",
"alarm_InverterRelayOpen": "Relè inverter aperto",
"alarm_InverterRelayShortCircuit": "Relè inverter in cortocircuito",
"alarm_OpenCircuitOfPowerGridRelay": "Relè di rete aperto",
"alarm_ShortCircuitOfPowerGridRelay": "Relè di rete in cortocircuito",
"alarm_GeneratorRelayOpenCircuit": "Relè del generatore aperto",
"alarm_GeneratorRelayShortCircuit": "Relè del generatore in cortocircuito",
"alarm_AbnormalInverter": "Inverter anomalo",
"alarm_ParallelCommunicationAlarm": "Allarme Comunicazione Parallela",
"alarm_ParallelModuleMissing": "Modulo Parallelo Mancante",
"alarm_DuplicateMachineNumbersForParallelModules": "Numeri Duplicati Moduli Paralleli",
"alarm_ParameterConflictInParallelModule": "Conflitto Parametri Modulo Parallelo",
"alarm_SystemDerating": "Riduzione Prestazioni Sistema",
"alarm_PvAccessMethodErrorAlarm": "Errore Metodo Accesso PV",
"alarm_ReservedAlarms4": "Allarme Riservato 4",
"alarm_ReservedAlarms5": "Allarme Riservato 5",
"alarm_ReverseMeterConnection": "Contatore Inverso",
"alarm_InverterSealPulse": "Impulso Sigillo Inverter",
"alarm_AbnormalDieselGeneratorVoltage": "Tensione anomala del generatore",
"alarm_AbnormalDieselGeneratorFrequency": "Frequenza anomala del generatore",
"alarm_DieselGeneratorVoltageReverseSequence": "Sequenza di fase invertita",
"alarm_DieselGeneratorVoltageOutOfPhase": "Generatore fuori fase",
"alarm_GeneratorOverload": "Sovraccarico del generatore",
"alarm_StringFault": "Guasto Stringa",
"alarm_PvStringPidQuickConnectAbnormal": "Connessione Rapida Anomala",
"alarm_DcSpdFunctionAbnormal": "Protezione SPD Anomala",
"alarm_PvShortCircuited": "Cortocircuito PV",
"alarm_PvBoostDriverAbnormal": "Driver di Boost Anomalo",
"alarm_AcSpdFunctionAbnormal": "Funzione SPD AC anomala",
"alarm_DcFuseBlown": "Fusibile DC saltato",
"alarm_DcInputVoltageTooHigh": "Tensione DC in ingresso troppo alta",
"alarm_PvReversed": "Polarità PV invertita",
"alarm_PidFunctionAbnormal": "Funzione PID anomala",
"alarm_PvStringDisconnected": "Stringa PV disconnessa",
"alarm_PvStringCurrentUnbalanced": "Corrente stringa PV squilibrata",
"alarm_NoUtilityGrid": "Nessuna rete elettrica",
"alarm_GridVoltageOutOfRange": "Tensione rete fuori limite",
"alarm_GridFrequencyOutOfRange": "Frequenza rete fuori limite",
"alarm_Overload": "Sovraccarico",
"alarm_MeterDisconnected": "Contatore scollegato",
"alarm_MeterReverselyConnected": "Contatore collegato al contrario",
"alarm_LinePeVoltageAbnormal": "Tensione anomala PE",
"alarm_PhaseSequenceError": "Errore sequenza fase",
"alarm_FanFailure": "Guasto Ventilatore",
"alarm_MeterAbnormal": "Contatore Anomalo",
"alarm_OptimizerCommunicationAbnormal": "Comunicazione Ottimizzatore Anomala",
"alarm_OverTemperature": "Temperatura Eccessiva",
"alarm_OverTemperatureAlarm": "Allarme Temperatura Eccessiva",
"alarm_NtcTemperatureSensorBroken": "Sensore temperatura guasto",
"alarm_SyncSignalAbnormal": "Segnale di sincronizzazione anomalo",
"alarm_GridStartupConditionsNotMet": "Condizioni di avvio rete non soddisfatte",
"alarm_BatteryCommunicationFailure": "Comunicazione batteria fallita",
"alarm_BatteryDisconnected": "Batteria scollegata",
"alarm_BatteryVoltageTooHigh": "Tensione batteria troppo alta",
"alarm_BatteryVoltageTooLow": "Tensione batteria troppo bassa",
"alarm_BatteryReverseConnected": "Batteria collegata al contrario",
"alarm_LeadAcidTempSensorDisconnected": "Sensore temperatura piombo acido scollegato",
"alarm_BatteryTemperatureOutOfRange": "Temperatura batteria fuori range",
"alarm_BmsFault": "Guasto BMS",
"alarm_LithiumBatteryOverload": "Sovraccarico Batteria Litio",
"alarm_BmsCommunicationAbnormal": "Comunicazione BMS Anomala",
"alarm_BatterySpdAbnormal": "SPD Batteria Anomalo",
"alarm_OutputDcComponentBiasAbnormal": "Bias DC di Uscita Anomalo",
"alarm_DcComponentOverHighOutputVoltage": "Tensione di uscita DC troppo alta",
"alarm_OffGridOutputVoltageTooLow": "Tensione di uscita off-grid troppo bassa",
"alarm_OffGridOutputVoltageTooHigh": "Tensione di uscita off-grid troppo alta",
"alarm_OffGridOutputOverCurrent": "Corrente di uscita off-grid troppo alta",
"alarm_OffGridOutputOverload": "Sovraccarico Uscita Off-Grid",
"alarm_BalancedCircuitAbnormal": "Circuiti Squilibrati Anomali",
"alarm_ExportLimitationFailSafe": "Limite Esportazione Sicurezza",
"alarm_DcBiasAbnormal": "Bias DC Anomalo",
"alarm_HighDcComponentOutputCurrent": "Alta Componente DC Corrente",
"alarm_BusVoltageSamplingAbnormal": "Campionamento tensione anomalo",
"alarm_RelayFault": "Guasto al relè",
"alarm_BusVoltageAbnormal": "Tensione bus anomala",
"alarm_InternalCommunicationFailure": "Comunicazione interna guasta",
"alarm_TemperatureSensorDisconnected": "Sensore temperatura scollegato",
"alarm_IgbtDriveFault": "Guasto guida IGBT",
"alarm_EepromError": "Errore EEPROM",
"alarm_AuxiliaryPowerAbnormal": "Alimentazione ausiliaria anomala",
"alarm_DcAcOvercurrentProtection": "Protezione sovracorrente DC/AC",
"alarm_CommunicationProtocolMismatch": "Protocollo di comunicazione non corrispondente",
"alarm_DspComFirmwareMismatch": "Incompatibilità firmware DSP/COM",
"alarm_DspSoftwareHardwareMismatch": "Incompatibilità software/hardware DSP",
"alarm_CpldAbnormal": "Anomalia CPLD",
"alarm_RedundancySamplingInconsistent": "Campionamento ridondante inconsistente",
"alarm_PwmPassThroughSignalFailure": "Guasto segnale PWM",
"alarm_AfciSelfTestFailure": "Test AFCI fallito",
"alarm_PvCurrentSamplingAbnormal": "Corrente PV anomala",
"alarm_AcCurrentSamplingAbnormal": "Corrente AC anomala",
"alarm_BusSoftbootFailure": "Avvio bus fallito",
"alarm_EpoFault": "Guasto EPO",
"alarm_MonitoringChipBootVerificationFailed": "Verifica avvio chip monitoraggio fallita",
"alarm_BmsCommunicationFailure": "Comunicazione BMS fallita",
"alarm_BmsChargeDischargeFailure": "Carica/scarica BMS bloccata",
"alarm_BatteryVoltageLow": "Tensione batteria bassa",
"alarm_BatteryVoltageHigh": "Tensione batteria alta",
"alarm_BatteryTemperatureAbnormal": "Temperatura batteria anomala",
"alarm_BatteryReversed": "Batteria invertita",
"alarm_BatteryOpenCircuit": "Circolazione batteria aperta",
"alarm_BatteryOverloadProtection": "Protezione sovraccarico batteria",
"alarm_Bus2VoltageAbnormal": "Tensione Bus2 anomala",
"alarm_BatteryChargeOcp": "Sovraccarico carica batteria",
"alarm_BatteryDischargeOcp": "Sovraccarico scarica batteria",
"alarm_BatterySoftStartFailed": "Avvio batteria fallito",
"alarm_EpsOutputShortCircuited": "Uscita EPS in cortocircuito",
"alarm_OffGridBusVoltageLow": "Tensione bus off-grid bassa",
"alarm_OffGridTerminalVoltageAbnormal": "Tensione terminale anomala",
"alarm_SoftStartFailed": "Avvio morbido fallito",
"alarm_OffGridOutputVoltageAbnormal": "Tensione di uscita anomala",
"alarm_BalancedCircuitSelfTestFailed": "Autotest circuito bilanciato fallito",
"alarm_HighDcComponentOutputVoltage": "Tensione di uscita DC elevata",
"alarm_OffGridParallelSignalAbnormal": "Segnale parallelo off-grid anomalo",
"alarm_AFCIFault": "Guasto AFCI",
"alarm_GFCIHigh": "Corrente di guasto a terra elevata",
"alarm_PVVoltageHigh": "Tensione PV elevata",
"alarm_OffGridBusVoltageTooLow": "Tensione Bus Fuori Rete Troppo Bassa"
}

View File

@ -141,10 +141,13 @@ function HeaderMenu(props: HeaderButtonsProps) {
English
</MenuItem>
<MenuItem value="de" onClick={() => handleLanguageSelect('de')}>
German
Deutsch
</MenuItem>
<MenuItem value="fr" onClick={() => handleLanguageSelect('fr')}>
French
Français
</MenuItem>
<MenuItem value="it" onClick={() => handleLanguageSelect('it')}>
Italiano
</MenuItem>
</Menu>
</div>