Compare commits

..

25 Commits

Author SHA1 Message Date
Yinyin Liu 69148410f2 roll out the previous commit to other products 2026-03-12 11:03:59 +01:00
Yinyin Liu cb61c2bd42 added connect process for Overview Page of Sodistorehome thus they only show available historical S3 data in case of offline 2026-03-12 10:48:42 +01:00
Yinyin Liu b4a296fd8a added Last Week option in Overview for Sinexcel and corrected Unit of Loads and commented Last Week for Growatt 2026-03-11 14:17:40 +01:00
Yinyin Liu 591e273bc7 Fixed edit mode in the conflict of RabbitMQ(status message updates all entries) and WebSocket 2026-03-11 12:22:19 +01:00
Yinyin Liu 1306ae6b9f digested hourly and daily data from S3 for Sinexcel 2026-03-11 11:43:55 +01:00
Yinyin Liu 27b84a0d46 improved balance of responsiveness of configurtaion page between UI and local changes 2026-03-11 10:54:31 +01:00
Yinyin Liu ec830b5800 increase ExoCmd to 18s to cover new 10s cycle loop for sodistore home 2026-03-11 07:29:08 +01:00
Yinyin Liu a1911325ad added installation name in monthly and yearly email service 2026-03-10 13:11:20 +01:00
Yinyin Liu ac034b9983 fixed monthly and yearly report overlap issue of PV and Battery performance 2026-03-10 12:50:36 +01:00
Yinyin Liu 6cf14e3483 daily tab design with hourly data and last week quick entry with self-efficiency on top 2026-03-10 12:32:01 +01:00
Yinyin Liu 0ac22ecbe9 fixed last week daily comsumption calcualtion error 2026-03-10 10:51:56 +01:00
Yinyin Liu f7ee347fc5 cahched weekly report 2026-03-10 10:50:17 +01:00
Yinyin Liu 7f972d13c3 fixed remember me token issue 2026-03-10 08:50:46 +01:00
Yinyin Liu f381f034d3 removed commited plan file 2026-03-09 16:39:31 +01:00
Yinyin Liu 8cd602c5cd added weather API to generate predition for weekly report 2026-03-09 16:24:29 +01:00
Yinyin Liu 57ee8be520 added Guide Tour button on Monitor to help the user navigate different tabs 2026-03-09 14:51:04 +01:00
Yinyin Liu 98abd68366 Added network provider in Information tab 2026-03-09 13:43:11 +01:00
Yinyin Liu c102ab3335 updated multi-inverter sinexcel data path 2026-03-09 12:27:02 +01:00
Yinyin Liu 401d82ea7a speed up the load of Live View and Battery View in Sodistore Home 2026-03-09 11:51:35 +01:00
Yinyin Liu 2e52b9ee15 translated hard-code English words to match selected language system, e.g., german, french and italian 2026-03-09 11:23:09 +01:00
Yinyin Liu 66803a2b34 unified Sinexcel and Growatt's operating priority 2026-03-09 10:57:12 +01:00
Yinyin Liu 37380e581f remove AI-powered Alarm Diagnosis Test demo 2026-03-09 10:24:41 +01:00
Yinyin Liu 5359ebba70 remove Battery Temperature in Overview and Detailed Battery View 2026-03-09 10:23:15 +01:00
Yinyin Liu f8b9428ce4 Merge feature/ticket-dashboard: backend data layer, CRUD, AI diagnosis service, 9 endpoints 2026-03-06 09:52:52 +01:00
Yinyin Liu 9cee5398d4 added ticket dashboard backend: data models, CRUD, AI diagnosis service, and 9 controller endpoints
Week 1 of ticket dashboard MVP — backend only:
- 4 new SQLite tables: Ticket, TicketComment, TicketAiDiagnosis, TicketTimelineEvent
- CRUD methods in Database/ partial classes with cascade delete
- TicketDiagnosticService for async Mistral AI diagnosis (fire-and-forget)
- 9 admin-only Controller endpoints for ticket CRUD, comments, and detail view
- Ticket cleanup integrated into Delete(Installation) cascade
2026-03-05 14:03:34 +01:00
84 changed files with 4381 additions and 689 deletions

View File

@ -753,6 +753,16 @@ public class Controller : ControllerBase
return installation.HideParentIfUserHasNoAccessToParent(session!.User);
}
[HttpGet(nameof(GetNetworkProviders))]
public ActionResult<IReadOnlyList<string>> GetNetworkProviders(Token authToken)
{
var session = Db.GetSession(authToken);
if (session is null)
return Unauthorized();
return Ok(NetworkProviderService.GetProviders());
}
[HttpPost(nameof(AcknowledgeError))]
public ActionResult AcknowledgeError(Int64 id, Token authToken)
{
@ -912,12 +922,13 @@ public class Controller : ControllerBase
// ── 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.
/// Returns a weekly performance report. Serves from cache if available;
/// generates fresh on first request or when forceRegenerate is true.
/// </summary>
[HttpGet(nameof(GetWeeklyReport))]
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(
Int64 installationId, Token authToken, String? language = null, String? weekStart = null)
Int64 installationId, Token authToken, String? language = null,
String? weekStart = null, Boolean forceRegenerate = false)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
@ -939,8 +950,41 @@ public class Controller : ControllerBase
try
{
var lang = language ?? user.Language ?? "en";
// Compute target week dates for cache lookup
DateOnly periodStart, periodEnd;
if (weekStartDate.HasValue)
{
periodStart = weekStartDate.Value;
periodEnd = weekStartDate.Value.AddDays(6);
}
else
{
(periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek();
}
var periodStartStr = periodStart.ToString("yyyy-MM-dd");
var periodEndStr = periodEnd.ToString("yyyy-MM-dd");
// Cache-first: check if a cached report exists for this week
if (!forceRegenerate)
{
var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr);
if (cached != null)
{
var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang);
if (cachedResponse != null)
{
Console.WriteLine($"[GetWeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}{periodEndStr}, language={lang}");
return Ok(cachedResponse);
}
}
}
// Cache miss or forceRegenerate: generate fresh
Console.WriteLine($"[GetWeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}{periodEndStr}");
var report = await WeeklyReportService.GenerateReportAsync(
installationId, installation.InstallationName, lang, weekStartDate);
installationId, installation.Name, lang, weekStartDate);
// Persist weekly summary and seed AiInsightCache for this language
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
@ -971,8 +1015,8 @@ public class Controller : ControllerBase
try
{
var lang = user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang);
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang);
await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang, user.Name);
return Ok(new { message = $"Report sent to {emailAddress}" });
}
catch (Exception ex)
@ -1191,6 +1235,153 @@ public class Controller : ControllerBase
return Ok(new { count = records.Count, records });
}
[HttpGet(nameof(GetHourlyRecords))]
public ActionResult<List<HourlyEnergyRecord>> GetHourlyRecords(
Int64 installationId, String from, String to, 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();
if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) ||
!DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate))
return BadRequest("from and to must be in yyyy-MM-dd format.");
var records = Db.GetHourlyRecords(installationId, fromDate, toDate);
return Ok(new { count = records.Count, records });
}
/// <summary>
/// Returns daily + hourly records for a date range.
/// Fallback chain: DB → JSON (local + S3) → xlsx. Caches to DB on first parse.
/// </summary>
[HttpGet(nameof(GetDailyDetailRecords))]
public ActionResult GetDailyDetailRecords(
Int64 installationId, String from, String to, 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();
if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) ||
!DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate))
return BadRequest("from and to must be in yyyy-MM-dd format.");
// 1. Try DB
var dailyRecords = Db.GetDailyRecords(installationId, fromDate, toDate);
var hourlyRecords = Db.GetHourlyRecords(installationId, fromDate, toDate);
if (dailyRecords.Count > 0 && hourlyRecords.Count > 0)
return Ok(FormatResult(dailyRecords, hourlyRecords));
// 2. Fallback: try JSON (local files + S3)
TryIngestFromJson(installationId, installation, fromDate, toDate);
dailyRecords = Db.GetDailyRecords(installationId, fromDate, toDate);
hourlyRecords = Db.GetHourlyRecords(installationId, fromDate, toDate);
if (dailyRecords.Count > 0 && hourlyRecords.Count > 0)
return Ok(FormatResult(dailyRecords, hourlyRecords));
// 3. Fallback: parse xlsx + cache to DB
TryIngestFromXlsx(installationId, fromDate, toDate);
dailyRecords = Db.GetDailyRecords(installationId, fromDate, toDate);
hourlyRecords = Db.GetHourlyRecords(installationId, fromDate, toDate);
return Ok(FormatResult(dailyRecords, hourlyRecords));
}
private static Object FormatResult(
List<DailyEnergyRecord> daily, List<HourlyEnergyRecord> hourly) => new
{
dailyRecords = new { count = daily.Count, records = daily },
hourlyRecords = new { count = hourly.Count, records = hourly },
};
private static void TryIngestFromJson(
Int64 installationId, Installation installation,
DateOnly fromDate, DateOnly toDate)
{
var jsonDir = Path.Combine(
Environment.CurrentDirectory, "tmp_report", "aggregated", installationId.ToString());
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
{
var isoDate = date.ToString("yyyy-MM-dd");
var fileName = AggregatedJsonParser.ToJsonFileName(date);
// Try local file first
var localPath = Path.Combine(jsonDir, fileName);
String? content = System.IO.File.Exists(localPath) ? System.IO.File.ReadAllText(localPath) : null;
// Try S3 if no local file
content ??= AggregatedJsonParser.TryReadFromS3(installation, isoDate)
.GetAwaiter().GetResult();
if (content is null) continue;
DailyIngestionService.IngestJsonContent(installationId, content);
}
}
private static void TryIngestFromXlsx(
Int64 installationId, DateOnly fromDate, DateOnly toDate)
{
var xlsxFiles = WeeklyReportService.GetRelevantXlsxFiles(installationId, fromDate, toDate);
if (xlsxFiles.Count == 0) return;
foreach (var xlsxPath in xlsxFiles)
{
foreach (var day in ExcelDataParser.Parse(xlsxPath))
{
if (Db.DailyRecordExists(installationId, day.Date))
continue;
Db.Create(new DailyEnergyRecord
{
InstallationId = installationId,
Date = day.Date,
PvProduction = day.PvProduction,
LoadConsumption = day.LoadConsumption,
GridImport = day.GridImport,
GridExport = day.GridExport,
BatteryCharged = day.BatteryCharged,
BatteryDischarged = day.BatteryDischarged,
CreatedAt = DateTime.UtcNow.ToString("o"),
});
}
foreach (var hour in ExcelDataParser.ParseHourly(xlsxPath))
{
var dateHour = $"{hour.DateTime:yyyy-MM-dd HH}";
if (Db.HourlyRecordExists(installationId, dateHour))
continue;
Db.Create(new HourlyEnergyRecord
{
InstallationId = installationId,
Date = hour.DateTime.ToString("yyyy-MM-dd"),
Hour = hour.Hour,
DateHour = dateHour,
DayOfWeek = hour.DayOfWeek,
IsWeekend = hour.IsWeekend,
PvKwh = hour.PvKwh,
LoadKwh = hour.LoadKwh,
GridImportKwh = hour.GridImportKwh,
BatteryChargedKwh = hour.BatteryChargedKwh,
BatteryDischargedKwh = hour.BatteryDischargedKwh,
BattSoC = hour.BattSoC,
CreatedAt = DateTime.UtcNow.ToString("o"),
});
}
}
}
/// <summary>
/// Deletes DailyEnergyRecord rows for an installation in the given date range.
/// Safe to use during testing — only removes daily records, not report summaries.
@ -1247,7 +1438,7 @@ public class Controller : ControllerBase
{
var lang = user.Language ?? "en";
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.Name, emailAddress, lang, user.Name);
return Ok(new { message = $"Monthly report sent to {emailAddress}" });
}
catch (Exception ex)
@ -1276,7 +1467,7 @@ public class Controller : ControllerBase
{
var lang = user.Language ?? "en";
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
await ReportEmailService.SendYearlyReportEmailAsync(report, installation.Name, emailAddress, lang, user.Name);
return Ok(new { message = $"Yearly report sent to {emailAddress}" });
}
catch (Exception ex)
@ -1746,7 +1937,171 @@ public class Controller : ControllerBase
return Ok(result);
}
// ═══════════════════════════════════════════════
// TICKET ENDPOINTS (admin-only)
// ═══════════════════════════════════════════════
[HttpGet(nameof(GetAllTickets))]
public ActionResult<IEnumerable<Ticket>> GetAllTickets(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
return Db.GetAllTickets();
}
[HttpGet(nameof(GetTicketsForInstallation))]
public ActionResult<IEnumerable<Ticket>> GetTicketsForInstallation(Int64 installationId, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null) return NotFound();
return Db.GetTicketsForInstallation(installationId);
}
[HttpGet(nameof(GetTicketById))]
public ActionResult<Ticket> GetTicketById(Int64 id, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var ticket = Db.GetTicketById(id);
return ticket is null ? NotFound() : ticket;
}
[HttpPost(nameof(CreateTicket))]
public ActionResult<Ticket> CreateTicket([FromBody] Ticket ticket, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
ticket.CreatedByUserId = user.Id;
ticket.CreatedAt = DateTime.UtcNow;
ticket.UpdatedAt = DateTime.UtcNow;
ticket.Status = (Int32)TicketStatus.Open;
if (!Db.Create(ticket)) return StatusCode(500, "Failed to create ticket.");
Db.Create(new TicketTimelineEvent
{
TicketId = ticket.Id,
EventType = (Int32)TimelineEventType.Created,
Description = $"Ticket created by {user.Name}.",
ActorType = (Int32)TimelineActorType.Human,
ActorId = user.Id,
CreatedAt = DateTime.UtcNow
});
// Fire-and-forget AI diagnosis
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id).SupressAwaitWarning();
return ticket;
}
[HttpPut(nameof(UpdateTicket))]
public ActionResult<Ticket> UpdateTicket([FromBody] Ticket ticket, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var existing = Db.GetTicketById(ticket.Id);
if (existing is null) return NotFound();
ticket.CreatedAt = existing.CreatedAt;
ticket.CreatedByUserId = existing.CreatedByUserId;
ticket.UpdatedAt = DateTime.UtcNow;
if (ticket.Status != existing.Status)
{
if (ticket.Status == (Int32)TicketStatus.Resolved)
ticket.ResolvedAt = DateTime.UtcNow;
Db.Create(new TicketTimelineEvent
{
TicketId = ticket.Id,
EventType = (Int32)TimelineEventType.StatusChanged,
Description = $"Status changed to {(TicketStatus)ticket.Status}.",
ActorType = (Int32)TimelineActorType.Human,
ActorId = user.Id,
CreatedAt = DateTime.UtcNow
});
}
return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed.");
}
[HttpDelete(nameof(DeleteTicket))]
public ActionResult DeleteTicket(Int64 id, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var ticket = Db.GetTicketById(id);
if (ticket is null) return NotFound();
return Db.Delete(ticket) ? Ok() : StatusCode(500, "Delete failed.");
}
[HttpGet(nameof(GetTicketComments))]
public ActionResult<IEnumerable<TicketComment>> GetTicketComments(Int64 ticketId, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
return Db.GetCommentsForTicket(ticketId);
}
[HttpPost(nameof(AddTicketComment))]
public ActionResult<TicketComment> AddTicketComment([FromBody] TicketComment comment, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var ticket = Db.GetTicketById(comment.TicketId);
if (ticket is null) return NotFound();
comment.AuthorType = (Int32)CommentAuthorType.Human;
comment.AuthorId = user.Id;
comment.CreatedAt = DateTime.UtcNow;
if (!Db.Create(comment)) return StatusCode(500, "Failed to add comment.");
Db.Create(new TicketTimelineEvent
{
TicketId = comment.TicketId,
EventType = (Int32)TimelineEventType.CommentAdded,
Description = $"Comment added by {user.Name}.",
ActorType = (Int32)TimelineActorType.Human,
ActorId = user.Id,
CreatedAt = DateTime.UtcNow
});
ticket.UpdatedAt = DateTime.UtcNow;
Db.Update(ticket);
return comment;
}
[HttpGet(nameof(GetTicketDetail))]
public ActionResult<Object> GetTicketDetail(Int64 id, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var ticket = Db.GetTicketById(id);
if (ticket is null) return NotFound();
return new
{
ticket,
comments = Db.GetCommentsForTicket(id),
diagnosis = Db.GetDiagnosisForTicket(id),
timeline = Db.GetTimelineForTicket(id)
};
}
}

View File

@ -54,4 +54,5 @@ public class Installation : TreeNode
public String OrderNumbers { get; set; }
public String VrmLink { get; set; } = "";
public string Configuration { get; set; } = "";
public string NetworkProvider { get; set; } = "";
}

View File

@ -436,9 +436,9 @@ public static class ExoCmd
public static async Task<Boolean> SendConfig(this Installation installation, Configuration config)
{
var maxRetransmissions = 4;
var maxRetransmissions = 6;
UdpClient udpClient = new UdpClient();
udpClient.Client.ReceiveTimeout = 2000;
udpClient.Client.ReceiveTimeout = 3000;
int port = 9000;
Console.WriteLine("Trying to reach installation with IP: " + installation.VpnIp);

View File

@ -43,6 +43,12 @@ public class WeeklyReportSummary
// AI insight for this week
public String AiInsight { get; set; } = "";
/// <summary>
/// Full serialized WeeklyReportResponse (with AiInsight cleared).
/// Used for cache-first serving — avoids regenerating numeric data + Mistral call.
/// </summary>
public String ResponseJson { get; set; } = "";
public String CreatedAt { get; set; } = "";
}

View File

@ -0,0 +1,30 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
public enum TicketStatus { Open = 0, InProgress = 1, Escalated = 2, Resolved = 3, Closed = 4 }
public enum TicketPriority { Critical = 0, High = 1, Medium = 2, Low = 3 }
public enum TicketCategory { Hardware = 0, Software = 1, DataApi = 2, UserAccess = 3 }
public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 }
public class Ticket
{
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
public String Subject { get; set; } = "";
public String Description { get; set; } = "";
[Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open;
public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium;
public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware;
public Int32 Source { get; set; } = (Int32)TicketSource.Manual;
[Indexed] public Int64 InstallationId { get; set; }
public Int64? AssigneeId { get; set; }
[Indexed] public Int64 CreatedByUserId { get; set; }
public String Tags { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ResolvedAt { get; set; }
}

View File

@ -0,0 +1,22 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
public enum DiagnosisStatus { Pending = 0, Analyzing = 1, Completed = 2, Failed = 3 }
public enum DiagnosisFeedback { Accepted = 0, Rejected = 1, Overridden = 2 }
public class TicketAiDiagnosis
{
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
[Indexed] public Int64 TicketId { get; set; }
public Int32 Status { get; set; } = (Int32)DiagnosisStatus.Pending;
public String? RootCause { get; set; }
public Double? Confidence { get; set; }
public String? RecommendedActions { get; set; } // JSON array string
public String? SimilarTicketIds { get; set; } // comma-separated
public Int32? Feedback { get; set; } // null = no feedback yet
public String? OverrideText { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
}

View File

@ -0,0 +1,16 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
public enum CommentAuthorType { Human = 0, AiAgent = 1 }
public class TicketComment
{
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
[Indexed] public Int64 TicketId { get; set; }
public Int32 AuthorType { get; set; } = (Int32)CommentAuthorType.Human;
public Int64? AuthorId { get; set; }
public String Body { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,23 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
public enum TimelineEventType
{
Created = 0, StatusChanged = 1, Assigned = 2,
CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5
}
public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 }
public class TicketTimelineEvent
{
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
[Indexed] public Int64 TicketId { get; set; }
public Int32 EventType { get; set; }
public String Description { get; set; } = "";
public Int32 ActorType { get; set; } = (Int32)TimelineActorType.System;
public Int64? ActorId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -75,6 +75,12 @@ public static partial class Db
public static Boolean Create(HourlyEnergyRecord record) => Insert(record);
public static Boolean Create(AiInsightCache cache) => Insert(cache);
// Ticket system
public static Boolean Create(Ticket ticket) => Insert(ticket);
public static Boolean Create(TicketComment comment) => Insert(comment);
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
public static void HandleAction(UserAction newAction)
{
//Find the total number of actions for this installation

View File

@ -32,6 +32,12 @@ public static partial class Db
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
// Ticket system tables
public static TableQuery<Ticket> Tickets => Connection.Table<Ticket>();
public static TableQuery<TicketComment> TicketComments => Connection.Table<TicketComment>();
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
public static void Init()
{
@ -63,6 +69,12 @@ public static partial class Db
Connection.CreateTable<DailyEnergyRecord>();
Connection.CreateTable<HourlyEnergyRecord>();
Connection.CreateTable<AiInsightCache>();
// Ticket system tables
Connection.CreateTable<Ticket>();
Connection.CreateTable<TicketComment>();
Connection.CreateTable<TicketAiDiagnosis>();
Connection.CreateTable<TicketTimelineEvent>();
});
// One-time migration: normalize legacy long-form language values to ISO codes
@ -112,6 +124,12 @@ public static partial class Db
fileConnection.CreateTable<HourlyEnergyRecord>();
fileConnection.CreateTable<AiInsightCache>();
// Ticket system tables
fileConnection.CreateTable<Ticket>();
fileConnection.CreateTable<TicketComment>();
fileConnection.CreateTable<TicketAiDiagnosis>();
fileConnection.CreateTable<TicketTimelineEvent>();
return fileConnection;
//return CopyDbToMemory(fileConnection);
}

View File

@ -124,6 +124,17 @@ public static partial class Db
Warnings .Delete(w => w.InstallationId == installation.Id);
UserActions .Delete(a => a.InstallationId == installation.Id);
// Clean up tickets and their children for this installation
var ticketIds = Tickets.Where(t => t.InstallationId == installation.Id)
.Select(t => t.Id).ToList();
foreach (var tid in ticketIds)
{
TicketComments .Delete(c => c.TicketId == tid);
TicketAiDiagnoses .Delete(d => d.TicketId == tid);
TicketTimelineEvents.Delete(e => e.TicketId == tid);
}
Tickets.Delete(t => t.InstallationId == installation.Id);
return Installations.Delete(i => i.Id == installation.Id) > 0;
}
}
@ -199,6 +210,21 @@ public static partial class Db
if (count > 0) Backup();
}
public static Boolean Delete(Ticket ticket)
{
var deleteSuccess = RunTransaction(DeleteTicketAndChildren);
if (deleteSuccess) Backup();
return deleteSuccess;
Boolean DeleteTicketAndChildren()
{
TicketComments .Delete(c => c.TicketId == ticket.Id);
TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id);
TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id);
return Tickets.Delete(t => t.Id == ticket.Id) > 0;
}
}
/// <summary>
/// Deletes all report records older than 1 year. Called annually on Jan 2
/// after yearly reports are created. Uses fetch-then-delete for string-compared

View File

@ -77,6 +77,18 @@ public static partial class Db
.ToList();
}
/// <summary>
/// Finds a cached weekly report whose period overlaps with the given date range.
/// Uses overlap logic (not exact match) because PeriodStart may be offset
/// if the first day of the week has no data.
/// </summary>
public static WeeklyReportSummary? GetWeeklyReportForWeek(Int64 installationId, String periodStart, String periodEnd)
=> WeeklyReports
.Where(r => r.InstallationId == installationId)
.ToList()
.FirstOrDefault(r => String.Compare(r.PeriodStart, periodEnd, StringComparison.Ordinal) <= 0
&& String.Compare(r.PeriodEnd, periodStart, StringComparison.Ordinal) >= 0);
public static List<MonthlyReportSummary> GetMonthlyReports(Int64 installationId)
=> MonthlyReports
.Where(r => r.InstallationId == installationId)
@ -104,13 +116,9 @@ public static partial class Db
{
var fromStr = from.ToString("yyyy-MM-dd");
var toStr = to.ToString("yyyy-MM-dd");
return DailyRecords
.Where(r => r.InstallationId == installationId)
.ToList()
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
.OrderBy(r => r.Date)
.ToList();
return Connection.Query<DailyEnergyRecord>(
"SELECT * FROM DailyEnergyRecord WHERE InstallationId = ? AND Date >= ? AND Date <= ? ORDER BY Date",
installationId, fromStr, toStr);
}
/// <summary>
@ -129,13 +137,9 @@ public static partial class Db
{
var fromStr = from.ToString("yyyy-MM-dd");
var toStr = to.ToString("yyyy-MM-dd");
return HourlyRecords
.Where(r => r.InstallationId == installationId)
.ToList()
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
.OrderBy(r => r.Date).ThenBy(r => r.Hour)
.ToList();
return Connection.Query<HourlyEnergyRecord>(
"SELECT * FROM HourlyEnergyRecord WHERE InstallationId = ? AND Date >= ? AND Date <= ? ORDER BY Date, Hour",
installationId, fromStr, toStr);
}
/// <summary>
@ -156,4 +160,33 @@ public static partial class Db
&& c.ReportId == reportId
&& c.Language == language)
?.InsightText;
// ── Ticket Queries ──────────────────────────────────────────────────
public static Ticket? GetTicketById(Int64 id)
=> Tickets.FirstOrDefault(t => t.Id == id);
public static List<Ticket> GetAllTickets()
=> Tickets.OrderByDescending(t => t.UpdatedAt).ToList();
public static List<Ticket> GetTicketsForInstallation(Int64 installationId)
=> Tickets
.Where(t => t.InstallationId == installationId)
.OrderByDescending(t => t.CreatedAt)
.ToList();
public static List<TicketComment> GetCommentsForTicket(Int64 ticketId)
=> TicketComments
.Where(c => c.TicketId == ticketId)
.OrderBy(c => c.CreatedAt)
.ToList();
public static TicketAiDiagnosis? GetDiagnosisForTicket(Int64 ticketId)
=> TicketAiDiagnoses.FirstOrDefault(d => d.TicketId == ticketId);
public static List<TicketTimelineEvent> GetTimelineForTicket(Int64 ticketId)
=> TicketTimelineEvents
.Where(e => e.TicketId == ticketId)
.OrderBy(e => e.CreatedAt)
.ToList();
}

View File

@ -56,4 +56,20 @@ public static partial class Db
}
}
/// <summary>
/// Updates ONLY the Status column for an installation.
/// This avoids a full-row overwrite that can race with TestingMode changes.
/// </summary>
public static Boolean UpdateInstallationStatus(Int64 installationId, int status)
{
var rows = Connection.Execute(
"UPDATE Installation SET Status = ? WHERE Id = ?",
status, installationId);
if (rows > 0) Backup();
return rows > 0;
}
// Ticket system
public static Boolean Update(Ticket ticket) => Update(obj: ticket);
public static Boolean Update(TicketAiDiagnosis diagnosis) => Update(obj: diagnosis);
}

View File

@ -27,6 +27,8 @@ public static class Program
Db.Init();
LoadEnvFile();
DiagnosticService.Initialize();
TicketDiagnosticService.Initialize();
NetworkProviderService.Initialize();
AlarmReviewService.StartDailyScheduler();
// DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly

View File

@ -0,0 +1,162 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using InnovEnergy.App.Backend.DataTypes;
using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.Lib.S3Utils;
using InnovEnergy.Lib.S3Utils.DataTypes;
using S3Region = InnovEnergy.Lib.S3Utils.DataTypes.S3Region;
namespace InnovEnergy.App.Backend.Services;
/// <summary>
/// Parses NDJSON aggregated data files generated by SodistoreHome devices.
/// Each file (DDMMYYYY.json) contains one JSON object per line:
/// - Type "Hourly": per-hour kWh values (already computed, no diffing needed)
/// - Type "Daily": daily totals
/// </summary>
public static class AggregatedJsonParser
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
public static List<DailyEnergyData> ParseDaily(String ndjsonContent)
{
var dailyByDate = new SortedDictionary<String, DailyEnergyData>();
foreach (var line in ndjsonContent.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (!line.Contains("\"Type\":\"Daily\""))
continue;
try
{
var raw = JsonSerializer.Deserialize<DailyJsonDto>(line, JsonOpts);
if (raw is null) continue;
var date = raw.Timestamp.ToString("yyyy-MM-dd");
dailyByDate[date] = new DailyEnergyData
{
Date = date,
PvProduction = Math.Round(raw.DailySelfGeneratedElectricity, 4),
GridImport = Math.Round(raw.DailyElectricityPurchased, 4),
GridExport = Math.Round(raw.DailyElectricityFed, 4),
BatteryCharged = Math.Round(raw.BatteryDailyChargeEnergy, 4),
BatteryDischarged = Math.Round(raw.BatteryDailyDischargeEnergy, 4),
LoadConsumption = Math.Round(raw.DailyLoadPowerConsumption, 4),
};
}
catch (Exception ex)
{
Console.Error.WriteLine($"[AggregatedJsonParser] Skipping daily line: {ex.Message}");
}
}
Console.WriteLine($"[AggregatedJsonParser] Parsed {dailyByDate.Count} daily record(s)");
return dailyByDate.Values.ToList();
}
public static List<HourlyEnergyData> ParseHourly(String ndjsonContent)
{
var result = new List<HourlyEnergyData>();
foreach (var line in ndjsonContent.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (!line.Contains("\"Type\":\"Hourly\""))
continue;
try
{
var raw = JsonSerializer.Deserialize<HourlyJsonDto>(line, JsonOpts);
if (raw is null) continue;
var dt = new DateTime(
raw.Timestamp.Year, raw.Timestamp.Month, raw.Timestamp.Day,
raw.Timestamp.Hour, 0, 0);
result.Add(new HourlyEnergyData
{
DateTime = dt,
Hour = dt.Hour,
DayOfWeek = dt.DayOfWeek.ToString(),
IsWeekend = dt.DayOfWeek is System.DayOfWeek.Saturday or System.DayOfWeek.Sunday,
PvKwh = Math.Round(raw.SelfGeneratedElectricity, 4),
GridImportKwh = Math.Round(raw.ElectricityPurchased, 4),
BatteryChargedKwh = Math.Round(raw.BatteryChargeEnergy, 4),
BatteryDischargedKwh = Math.Round(raw.BatteryDischargeEnergy, 4),
LoadKwh = Math.Round(raw.LoadPowerConsumption, 4),
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"[AggregatedJsonParser] Skipping hourly line: {ex.Message}");
}
}
Console.WriteLine($"[AggregatedJsonParser] Parsed {result.Count} hourly record(s)");
return result;
}
/// <summary>
/// Converts ISO date "yyyy-MM-dd" to device filename format "ddMMyyyy".
/// </summary>
public static String ToJsonFileName(String isoDate)
{
var d = DateOnly.ParseExact(isoDate, "yyyy-MM-dd");
return d.ToString("ddMMyyyy") + ".json";
}
public static String ToJsonFileName(DateOnly date) => date.ToString("ddMMyyyy") + ".json";
/// <summary>
/// Tries to read an aggregated JSON file from the installation's S3 bucket.
/// S3 key: DDMMYYYY.json (directly in bucket root).
/// Returns file content or null if not found / error.
/// </summary>
public static async Task<String?> TryReadFromS3(Installation installation, String isoDate)
{
try
{
var fileName = ToJsonFileName(isoDate);
var region = new S3Region($"https://{installation.S3Region}.{installation.S3Provider}", ExoCmd.S3Credentials!);
var bucket = region.Bucket(installation.BucketName());
var s3Url = bucket.Path(fileName);
return await s3Url.GetObjectAsString();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[AggregatedJsonParser] S3 read failed for {isoDate}: {ex.Message}");
return null;
}
}
// --- JSON DTOs ---
private sealed class HourlyJsonDto
{
public String Type { get; set; } = "";
public DateTime Timestamp { get; set; }
public Double SelfGeneratedElectricity { get; set; }
public Double ElectricityPurchased { get; set; }
public Double ElectricityFed { get; set; }
public Double BatteryChargeEnergy { get; set; }
public Double BatteryDischargeEnergy { get; set; }
public Double LoadPowerConsumption { get; set; }
}
private sealed class DailyJsonDto
{
public String Type { get; set; } = "";
public DateTime Timestamp { get; set; }
public Double DailySelfGeneratedElectricity { get; set; }
public Double DailyElectricityPurchased { get; set; }
public Double DailyElectricityFed { get; set; }
public Double BatteryDailyChargeEnergy { get; set; }
public Double BatteryDailyDischargeEnergy { get; set; }
public Double DailyLoadPowerConsumption { get; set; }
}
}

View File

@ -4,12 +4,8 @@ using InnovEnergy.App.Backend.DataTypes;
namespace InnovEnergy.App.Backend.Services;
/// <summary>
/// Ingests daily energy totals from xlsx files into the DailyEnergyRecord SQLite table.
/// This is the source-of-truth population step for the report pipeline.
///
/// Current data source: xlsx files placed in tmp_report/{installationId}.xlsx
/// Future data source: S3 raw records (replace ExcelDataParser call with S3DailyExtractor)
///
/// Ingests daily energy totals into the DailyEnergyRecord SQLite table.
/// Data source priority: JSON (local) → JSON (S3) → xlsx fallback.
/// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the
/// IngestDailyData API endpoint.
/// </summary>
@ -18,6 +14,9 @@ public static class DailyIngestionService
private static readonly String TmpReportDir =
Environment.CurrentDirectory + "/tmp_report/";
private static readonly String JsonAggregatedDir =
Environment.CurrentDirectory + "/tmp_report/aggregated/";
private static Timer? _dailyTimer;
/// <summary>
@ -73,11 +72,119 @@ public static class DailyIngestionService
}
/// <summary>
/// Parses all xlsx files matching {installationId}*.xlsx in tmp_report/ and stores
/// any new days as DailyEnergyRecord rows. Supports multiple time-ranged files per
/// installation (e.g. 123_0203_0803.xlsx, 123_0901_1231.xlsx). Idempotent.
/// Ingests data for one installation. Tries JSON (local + S3) and xlsx.
/// Both sources are tried — idempotency checks prevent duplicates.
/// JSON provides recent data; xlsx provides historical data.
/// </summary>
public static async Task IngestInstallationAsync(Int64 installationId)
{
await TryIngestFromJson(installationId);
IngestFromXlsx(installationId);
}
private static async Task<Boolean> TryIngestFromJson(Int64 installationId)
{
var newDaily = 0;
var newHourly = 0;
var jsonDir = Path.Combine(JsonAggregatedDir, installationId.ToString());
// Collect JSON content from local files
var jsonFiles = Directory.Exists(jsonDir)
? Directory.GetFiles(jsonDir, "*.json")
: Array.Empty<String>();
foreach (var jsonPath in jsonFiles.OrderBy(f => f))
{
var content = File.ReadAllText(jsonPath);
var (d, h) = IngestJsonContent(installationId, content);
newDaily += d;
newHourly += h;
}
// Also try S3 for recent days (yesterday + today) if no local files found
if (jsonFiles.Length == 0)
{
var installation = Db.GetInstallationById(installationId);
if (installation is not null)
{
for (var daysBack = 0; daysBack <= 1; daysBack++)
{
var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack));
var isoDate = date.ToString("yyyy-MM-dd");
if (Db.DailyRecordExists(installationId, isoDate))
continue;
var content = await AggregatedJsonParser.TryReadFromS3(installation, isoDate);
if (content is null) continue;
var (d, h) = IngestJsonContent(installationId, content);
newDaily += d;
newHourly += h;
}
}
}
if (newDaily > 0 || newHourly > 0)
Console.WriteLine($"[DailyIngestion] Installation {installationId} (JSON): {newDaily} day(s), {newHourly} hour(s) ingested.");
return newDaily > 0 || newHourly > 0;
}
public static (Int32 daily, Int32 hourly) IngestJsonContent(Int64 installationId, String content)
{
var newDaily = 0;
var newHourly = 0;
foreach (var day in AggregatedJsonParser.ParseDaily(content))
{
if (Db.DailyRecordExists(installationId, day.Date))
continue;
Db.Create(new DailyEnergyRecord
{
InstallationId = installationId,
Date = day.Date,
PvProduction = day.PvProduction,
LoadConsumption = day.LoadConsumption,
GridImport = day.GridImport,
GridExport = day.GridExport,
BatteryCharged = day.BatteryCharged,
BatteryDischarged = day.BatteryDischarged,
CreatedAt = DateTime.UtcNow.ToString("o"),
});
newDaily++;
}
foreach (var hour in AggregatedJsonParser.ParseHourly(content))
{
var dateHour = $"{hour.DateTime:yyyy-MM-dd HH}";
if (Db.HourlyRecordExists(installationId, dateHour))
continue;
Db.Create(new HourlyEnergyRecord
{
InstallationId = installationId,
Date = hour.DateTime.ToString("yyyy-MM-dd"),
Hour = hour.Hour,
DateHour = dateHour,
DayOfWeek = hour.DayOfWeek,
IsWeekend = hour.IsWeekend,
PvKwh = hour.PvKwh,
LoadKwh = hour.LoadKwh,
GridImportKwh = hour.GridImportKwh,
BatteryChargedKwh = hour.BatteryChargedKwh,
BatteryDischargedKwh = hour.BatteryDischargedKwh,
BattSoC = 0,
CreatedAt = DateTime.UtcNow.ToString("o"),
});
newHourly++;
}
return (newDaily, newHourly);
}
private static void IngestFromXlsx(Int64 installationId)
{
if (!Directory.Exists(TmpReportDir))
{
@ -98,12 +205,8 @@ public static class DailyIngestionService
foreach (var xlsxPath in xlsxFiles.OrderBy(f => f))
{
// Ingest daily records
List<DailyEnergyData> days;
try
{
days = ExcelDataParser.Parse(xlsxPath);
}
try { days = ExcelDataParser.Parse(xlsxPath); }
catch (Exception ex)
{
Console.Error.WriteLine($"[DailyIngestion] Failed to parse daily {Path.GetFileName(xlsxPath)}: {ex.Message}");
@ -132,12 +235,8 @@ public static class DailyIngestionService
newDailyCount++;
}
// Ingest hourly records
List<HourlyEnergyData> hours;
try
{
hours = ExcelDataParser.ParseHourly(xlsxPath);
}
try { hours = ExcelDataParser.ParseHourly(xlsxPath); }
catch (Exception ex)
{
Console.Error.WriteLine($"[DailyIngestion] Failed to parse hourly {Path.GetFileName(xlsxPath)}: {ex.Message}");
@ -170,7 +269,6 @@ public static class DailyIngestionService
}
}
Console.WriteLine($"[DailyIngestion] Installation {installationId}: {newDailyCount} new day(s), {newHourlyCount} new hour(s) ingested ({totalParsed} days across {xlsxFiles.Length} file(s)).");
await Task.CompletedTask;
Console.WriteLine($"[DailyIngestion] Installation {installationId} (xlsx): {newDailyCount} new day(s), {newHourlyCount} new hour(s) ingested ({totalParsed} days across {xlsxFiles.Length} file(s)).");
}
}

View File

@ -0,0 +1,78 @@
using Flurl.Http;
using Newtonsoft.Json.Linq;
namespace InnovEnergy.App.Backend.Services;
/// <summary>
/// Fetches and caches the list of Swiss electricity network providers (Netzbetreiber)
/// from the ELCOM/LINDAS SPARQL endpoint. Refreshes every 24 hours.
/// </summary>
public static class NetworkProviderService
{
private static IReadOnlyList<string> _providers = Array.Empty<string>();
private static Timer? _refreshTimer;
private const string SparqlEndpoint = "https://ld.admin.ch/query";
private const string SparqlQuery = @"
PREFIX schema: <http://schema.org/>
SELECT DISTINCT ?name
FROM <https://lindas.admin.ch/elcom/electricityprice>
WHERE {
?operator a schema:Organization ;
schema:name ?name .
}
ORDER BY ?name";
public static void Initialize()
{
// Fire-and-forget initial load
Task.Run(RefreshAsync);
// Refresh every 24 hours
_refreshTimer = new Timer(
_ => Task.Run(RefreshAsync),
null,
TimeSpan.FromHours(24),
TimeSpan.FromHours(24)
);
Console.WriteLine("[NetworkProviderService] initialised.");
}
public static IReadOnlyList<string> GetProviders() => _providers;
private static async Task RefreshAsync()
{
try
{
var response = await SparqlEndpoint
.WithHeader("Accept", "application/sparql-results+json")
.PostUrlEncodedAsync(new { query = SparqlQuery });
var json = await response.GetStringAsync();
var parsed = JObject.Parse(json);
var names = parsed["results"]?["bindings"]?
.Select(b => b["name"]?["value"]?.ToString())
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct()
.OrderBy(n => n)
.ToList();
if (names is { Count: > 0 })
{
_providers = names!;
Console.WriteLine($"[NetworkProviderService] Loaded {names.Count} providers from ELCOM.");
}
else
{
Console.Error.WriteLine("[NetworkProviderService] SPARQL query returned no results.");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[NetworkProviderService] Failed to fetch providers: {ex.Message}");
}
}
}

View File

@ -185,6 +185,29 @@ public static class ReportAggregationService
foreach (var old in overlapping)
Db.WeeklyReports.Delete(r => r.Id == old.Id);
// Serialize full response (minus AI insight) for cache-first serving
var reportForCache = new WeeklyReportResponse
{
InstallationName = report.InstallationName,
PeriodStart = report.PeriodStart,
PeriodEnd = report.PeriodEnd,
CurrentWeek = report.CurrentWeek,
PreviousWeek = report.PreviousWeek,
TotalEnergySaved = report.TotalEnergySaved,
TotalSavingsCHF = report.TotalSavingsCHF,
DaysEquivalent = report.DaysEquivalent,
SelfSufficiencyPercent = report.SelfSufficiencyPercent,
SelfConsumptionPercent = report.SelfConsumptionPercent,
BatteryEfficiencyPercent = report.BatteryEfficiencyPercent,
GridDependencyPercent = report.GridDependencyPercent,
PvChangePercent = report.PvChangePercent,
ConsumptionChangePercent = report.ConsumptionChangePercent,
GridImportChangePercent = report.GridImportChangePercent,
DailyData = report.DailyData,
Behavior = report.Behavior,
AiInsight = "", // Language-dependent; stored in AiInsightCache
};
var summary = new WeeklyReportSummary
{
InstallationId = installationId,
@ -207,6 +230,7 @@ public static class ReportAggregationService
WeekdayAvgDailyLoad = report.Behavior?.WeekdayAvgDailyLoad ?? 0,
WeekendAvgDailyLoad = report.Behavior?.WeekendAvgDailyLoad ?? 0,
AiInsight = report.AiInsight,
ResponseJson = JsonConvert.SerializeObject(reportForCache),
CreatedAt = DateTime.UtcNow.ToString("o"),
};
@ -378,7 +402,8 @@ public static class ReportAggregationService
installationName, monthName, days.Count,
totalPv, totalConsump, totalGridIn, totalGridOut,
totalBattChg, totalBattDis, energySaved, savingsCHF,
selfSufficiency, batteryEff, language);
selfSufficiency, batteryEff, language,
installation?.Location, installation?.Country, installation?.Region);
var monthlySummary = new MonthlyReportSummary
{
@ -573,11 +598,31 @@ public static class ReportAggregationService
() => GenerateWeeklySummaryAiInsightAsync(report, installationName, language));
}
/// <summary>
/// Reconstructs a full WeeklyReportResponse from a cached WeeklyReportSummary.
/// Returns null if ResponseJson is empty (old records without cache data).
/// AI insight is fetched/generated per-language via AiInsightCache.
/// </summary>
public static async Task<WeeklyReportResponse?> ToWeeklyReportResponseAsync(
WeeklyReportSummary summary, String language)
{
if (String.IsNullOrEmpty(summary.ResponseJson))
return null;
var response = JsonConvert.DeserializeObject<WeeklyReportResponse>(summary.ResponseJson);
if (response == null)
return null;
response.AiInsight = await GetOrGenerateWeeklyInsightAsync(summary, language);
return response;
}
/// <summary>Cached-or-generated AI insight for a stored MonthlyReportSummary.</summary>
public static Task<String> GetOrGenerateMonthlyInsightAsync(
MonthlyReportSummary report, String language)
{
var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName
var installation = Db.GetInstallationById(report.InstallationId);
var installationName = installation?.InstallationName
?? $"Installation {report.InstallationId}";
var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy");
return GetOrGenerateInsightAsync("monthly", report.Id, language,
@ -587,7 +632,8 @@ public static class ReportAggregationService
report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged,
report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language));
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language,
installation?.Location, installation?.Country, installation?.Region));
}
/// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary>
@ -670,7 +716,8 @@ Exactly 4 bullet points. Each starts with ""- Title: description"" format.";
Double totalBattChg, Double totalBattDis,
Double energySaved, Double savingsCHF,
Double selfSufficiency, Double batteryEff,
String language = "en")
String language = "en",
String? location = null, String? country = null, String? region = null)
{
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
if (String.IsNullOrWhiteSpace(apiKey))
@ -681,24 +728,34 @@ Exactly 4 bullet points. Each starts with ""- Title: description"" format.";
// Determine which metric is weakest so the tip can be targeted
var weakMetric = batteryEff < 80 ? "battery" : selfSufficiency < 40 ? "self-sufficiency" : "general";
// Fetch weather forecast for the installation's location
var forecast = await WeatherService.GetForecastAsync(location, country, region);
var weatherBlock = forecast != null
? "\n" + WeatherService.FormatForPrompt(forecast) + "\n"
: "";
var weatherTipHint = forecast != null
? " Consider the upcoming 7-day weather forecast when suggesting the tip."
: "";
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Write a concise monthly performance summary in {langName} (4 bullet points, plain text, no markdown).
Write a concise monthly performance summary in {langName} (5 bullet points, plain text, no markdown).
MONTHLY FACTS for {monthName} ({weekCount} days of data):
- PV production: {totalPv:F1} kWh
- Total consumption: {totalConsump:F1} kWh
- Self-sufficiency: {selfSufficiency:F1}% (share of energy covered by solar, without drawing from grid)
- Self-sufficiency: {selfSufficiency:F1}% (share of energy covered by solar + battery, not bought from grid)
- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged, efficiency {batteryEff:F1}%
- Energy saved: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF saved (at {ElectricityPriceCHF} CHF/kWh)
{weatherBlock}
INSTRUCTIONS:
1. Savings: state exactly how much energy and money was saved this month. Positive framing.
2. Solar performance: how much the solar system produced and what self-sufficiency % means for the homeowner (e.g. ""Your solar system covered X% of your home's energy needs""). Do NOT mention battery here. Do NOT mention raw grid import kWh.
3. Battery: comment on battery utilization and efficiency. If efficiency < 80%, note it may need attention.
4. Tip: one specific actionable suggestion based on the weakest metric: {weakMetric}. If general, suggest the most impactful habit change based on the numbers above.
2. Energy independence: state the self-sufficiency percentage and what it means X% of the home's energy came from the combined solar and battery system, only Y% was purchased from the grid. Do NOT repeat raw grid import kWh.
3. Solar production: state how much the solar system produced this month and the daily average. Keep it factual. Do NOT repeat self-sufficiency percentage here.
4. Battery: comment on battery utilization and efficiency. If efficiency < 80%, note it may need attention. Do NOT repeat self-sufficiency percentage here.
5. Tip: one specific actionable suggestion based on the weakest metric: {weakMetric}.{weatherTipHint} If general, suggest the most impactful habit change based on the numbers above.
Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisks or formatting marks. No closing remarks. Exactly 5 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
return await CallMistralAsync(apiKey, prompt);
}
@ -718,7 +775,7 @@ Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisk
var langName = GetLanguageName(language);
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Write a concise annual performance summary in {langName} (4 bullet points, plain text, no markdown).
Write a concise annual performance summary in {langName} (5 bullet points, plain text, no markdown).
ANNUAL FACTS for {year} ({monthCount} months of data):
- Total PV production: {totalPv:F1} kWh
@ -731,11 +788,12 @@ ANNUAL FACTS for {year} ({monthCount} months of data):
INSTRUCTIONS:
1. Annual savings highlight: total energy and money saved for the year. Use the exact numbers provided.
2. System performance: comment on PV production and battery health indicators.
3. Year-over-year readiness: note any trends or areas of improvement.
4. Looking ahead: one strategic recommendation for the coming year.
2. Energy independence: state the self-sufficiency percentage X% of the home's energy came from the combined solar and battery system. Do NOT repeat raw grid import kWh.
3. Solar production: state total PV production for the year. Keep it factual. Do NOT repeat self-sufficiency percentage here.
4. Battery: comment on battery efficiency. If efficiency < 80%, note it may need attention. Do NOT repeat self-sufficiency percentage here.
5. Looking ahead: one strategic recommendation for the coming year.
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 5 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
return await CallMistralAsync(apiKey, prompt);
}

View File

@ -13,11 +13,11 @@ public static class ReportEmailService
/// 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")
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en", string customerName = null)
{
var strings = GetStrings(language);
var subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})";
var html = BuildHtmlEmail(report, strings);
var html = BuildHtmlEmail(report, strings, customerName);
var config = await ReadMailerConfig();
@ -80,7 +80,8 @@ public static class ReportEmailService
string GridIn,
string GridOut,
string BattInOut,
string Footer
string Footer,
string FooterLink
);
private static EmailStrings GetStrings(string language) => language switch
@ -104,8 +105,8 @@ public static class ReportEmailService
StayedAtHome: "Solar + Batterie, nicht vom Netz",
EstMoneySaved: "Geschätzte Ersparnis",
AtRate: "bei 0.39 CHF/kWh",
SolarCoverage: "Eigenversorgung",
FromSolar: "aus Solar + Batterie",
SolarCoverage: "Energieunabhängigkeit",
FromSolar: "aus eigenem Solar + Batterie System",
BatteryEff: "Batterie-Eff.",
OutVsIn: "Entladung vs. Ladung",
Day: "Tag",
@ -113,7 +114,8 @@ public static class ReportEmailService
GridIn: "Netz Ein",
GridOut: "Netz Aus",
BattInOut: "Batt. Laden/Entl.",
Footer: "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"
Footer: "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>",
FooterLink: "Detaillierte Berichte ansehen auf monitor.inesco.energy"
),
"fr" => new EmailStrings(
Title: "Rapport de performance hebdomadaire",
@ -134,8 +136,8 @@ public static class ReportEmailService
StayedAtHome: "solaire + batterie, non achetée au réseau",
EstMoneySaved: "Économies estimées",
AtRate: "à 0.39 CHF/kWh",
SolarCoverage: "Autosuffisance",
FromSolar: "du solaire + batterie",
SolarCoverage: "Indépendance énergétique",
FromSolar: "de votre système solaire + batterie",
BatteryEff: "Eff. batterie",
OutVsIn: "décharge vs charge",
Day: "Jour",
@ -143,7 +145,8 @@ public static class ReportEmailService
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>"
Footer: "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>",
FooterLink: "Consultez vos rapports détaillés sur monitor.inesco.energy"
),
"it" => new EmailStrings(
Title: "Rapporto settimanale delle prestazioni",
@ -164,8 +167,8 @@ public static class ReportEmailService
StayedAtHome: "solare + batteria, non acquistata dalla rete",
EstMoneySaved: "Risparmio stimato",
AtRate: "a 0.39 CHF/kWh",
SolarCoverage: "Autosufficienza",
FromSolar: "da solare + batteria",
SolarCoverage: "Indipendenza energetica",
FromSolar: "dal proprio impianto solare + batteria",
BatteryEff: "Eff. batteria",
OutVsIn: "scarica vs carica",
Day: "Giorno",
@ -173,7 +176,8 @@ public static class ReportEmailService
GridIn: "Rete Ent.",
GridOut: "Rete Usc.",
BattInOut: "Batt. Car./Sc.",
Footer: "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"
Footer: "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>",
FooterLink: "Visualizza i tuoi report dettagliati su monitor.inesco.energy"
),
_ => new EmailStrings(
Title: "Weekly Performance Report",
@ -194,8 +198,8 @@ public static class ReportEmailService
StayedAtHome: "solar + battery, not bought from grid",
EstMoneySaved: "Est. Money Saved",
AtRate: "at 0.39 CHF/kWh",
SolarCoverage: "Self-Sufficiency",
FromSolar: "from solar + battery",
SolarCoverage: "Energy Independence",
FromSolar: "from your own solar + battery system",
BatteryEff: "Battery Eff.",
OutVsIn: "discharge vs charge",
Day: "Day",
@ -203,16 +207,17 @@ public static class ReportEmailService
GridIn: "Grid In",
GridOut: "Grid Out",
BattInOut: "Batt. Ch./Dis.",
Footer: "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>"
Footer: "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>",
FooterLink: "View your detailed reports at monitor.inesco.energy"
)
};
// ── HTML email template ─────────────────────────────────────────────
public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en")
=> BuildHtmlEmail(r, GetStrings(language));
public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en", string customerName = null)
=> BuildHtmlEmail(r, GetStrings(language), customerName);
private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s)
private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s, string customerName = null)
{
var cur = r.CurrentWeek;
var prev = r.PreviousWeek;
@ -396,6 +401,7 @@ public static class ReportEmailService
<tr>
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
{s.Footer}
<div style=""margin-top:10px""><a href=""https://monitor.inesco.energy"" style=""color:#999;text-decoration:underline"">{s.FooterLink}</a></div>
</td>
</tr>
@ -455,7 +461,8 @@ public static class ReportEmailService
MonthlyReportSummary report,
string installationName,
string recipientEmail,
string language = "en")
string language = "en",
string customerName = null)
{
var monthNames = language switch { "de" => MonthNamesDe, "fr" => MonthNamesFr, "it" => MonthNamesIt, _ => MonthNamesEn };
var monthName = report.Month >= 1 && report.Month <= 12 ? monthNames[report.Month] : report.Month.ToString();
@ -465,7 +472,7 @@ public static class ReportEmailService
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
$"{report.WeekCount} {s.CountLabel}", s);
$"{report.WeekCount} {s.CountLabel}", s, customerName);
await SendHtmlEmailAsync(subject, html, recipientEmail);
}
@ -474,7 +481,8 @@ public static class ReportEmailService
YearlyReportSummary report,
string installationName,
string recipientEmail,
string language = "en")
string language = "en",
string customerName = null)
{
var s = GetAggregatedStrings(language, "yearly");
var subject = $"{s.Title} — {installationName} ({report.Year})";
@ -482,7 +490,7 @@ public static class ReportEmailService
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
$"{report.MonthCount} {s.CountLabel}", s);
$"{report.MonthCount} {s.CountLabel}", s, customerName);
await SendHtmlEmailAsync(subject, html, recipientEmail);
}
@ -518,7 +526,8 @@ public static class ReportEmailService
string GridImport, string GridExport, string BatteryInOut,
string SolarEnergyUsed, string StayedAtHome, string EstMoneySaved,
string AtRate, string SolarCoverage, string FromSolar,
string BatteryEff, string OutVsIn, string CountLabel, string Footer
string BatteryEff, string OutVsIn, string CountLabel, string Footer,
string FooterLink
);
private static AggregatedEmailStrings GetAggregatedStrings(string language, string type) => (language, type) switch
@ -527,50 +536,58 @@ public static class ReportEmailService
"Monatlicher Leistungsbericht", "Monatliche Erkenntnisse", "Monatliche Zusammenfassung", "Ihre Ersparnisse diesen Monat",
"Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen",
"Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh",
"Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung",
"Tage aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
"Energieunabhängigkeit", "aus eigenem Solar + Batterie System", "Batterie-Eff.", "Entladung vs. Ladung",
"Tage aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>",
"Detaillierte Berichte ansehen auf monitor.inesco.energy"),
("de", "yearly") => new AggregatedEmailStrings(
"Jährlicher Leistungsbericht", "Jährliche Erkenntnisse", "Jährliche Zusammenfassung", "Ihre Ersparnisse dieses Jahr",
"Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen",
"Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh",
"Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung",
"Monate aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
"Energieunabhängigkeit", "aus eigenem Solar + Batterie System", "Batterie-Eff.", "Entladung vs. Ladung",
"Monate aggregiert", "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>",
"Detaillierte Berichte ansehen auf monitor.inesco.energy"),
("fr", "monthly") => new AggregatedEmailStrings(
"Rapport de performance mensuel", "Aperçus du mois", "Résumé du mois", "Vos économies ce mois",
"Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge",
"Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh",
"Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge",
"jours agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
"Indépendance énergétique", "de votre système solaire + batterie", "Eff. batterie", "décharge vs charge",
"jours agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>",
"Consultez vos rapports détaillés sur monitor.inesco.energy"),
("fr", "yearly") => new AggregatedEmailStrings(
"Rapport de performance annuel", "Aperçus de l'année", "Résumé de l'année", "Vos économies cette année",
"Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge",
"Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh",
"Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge",
"mois agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
"Indépendance énergétique", "de votre système solaire + batterie", "Eff. batterie", "décharge vs charge",
"mois agrégés", "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>",
"Consultez vos rapports détaillés sur monitor.inesco.energy"),
("it", "monthly") => new AggregatedEmailStrings(
"Rapporto mensile delle prestazioni", "Approfondimenti mensili", "Riepilogo mensile", "I tuoi risparmi questo mese",
"Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica",
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
"Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica",
"giorni aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
"Indipendenza energetica", "dal proprio impianto solare + batteria", "Eff. batteria", "scarica vs carica",
"giorni aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>",
"Visualizza i tuoi report dettagliati su monitor.inesco.energy"),
("it", "yearly") => new AggregatedEmailStrings(
"Rapporto annuale delle prestazioni", "Approfondimenti annuali", "Riepilogo annuale", "I tuoi risparmi quest'anno",
"Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica",
"Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh",
"Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica",
"mesi aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
"Indipendenza energetica", "dal proprio impianto solare + batteria", "Eff. batteria", "scarica vs carica",
"mesi aggregati", "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>",
"Visualizza i tuoi report dettagliati su monitor.inesco.energy"),
(_, "monthly") => new AggregatedEmailStrings(
"Monthly Performance Report", "Monthly Insights", "Monthly Summary", "Your Savings This Month",
"Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge",
"Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh",
"Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge",
"days aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
"Energy Independence", "from your own solar + battery system", "Battery Eff.", "discharge vs charge",
"days aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>",
"View your detailed reports at monitor.inesco.energy"),
_ => new AggregatedEmailStrings(
"Annual Performance Report", "Annual Insights", "Annual Summary", "Your Savings This Year",
"Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge",
"Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh",
"Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge",
"months aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>")
"Energy Independence", "from your own solar + battery system", "Battery Eff.", "discharge vs charge",
"months aggregated", "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>",
"View your detailed reports at monitor.inesco.energy")
};
// ── Aggregated HTML email template ────────────────────────────────────
@ -580,7 +597,7 @@ public static class ReportEmailService
double pvProduction, double consumption, double gridImport, double gridExport,
double batteryCharged, double batteryDischarged, double energySaved, double savingsCHF,
double selfSufficiency, double batteryEfficiency, string aiInsight,
string countLabel, AggregatedEmailStrings s)
string countLabel, AggregatedEmailStrings s, string customerName = null)
{
var insightLines = aiInsight
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
@ -660,6 +677,7 @@ public static class ReportEmailService
<tr>
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
{s.Footer}
<div style=""margin-top:10px""><a href=""https://monitor.inesco.energy"" style=""color:#999;text-decoration:underline"">{s.FooterLink}</a></div>
</td>
</tr>

View File

@ -0,0 +1,198 @@
using Flurl.Http;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
using Newtonsoft.Json;
namespace InnovEnergy.App.Backend.Services;
/// <summary>
/// Generates AI-powered diagnoses for support tickets.
/// Runs async after ticket creation; stores result in TicketAiDiagnosis table.
/// </summary>
public static class TicketDiagnosticService
{
private static string _apiKey = "";
private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions";
public static void Initialize()
{
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
Console.Error.WriteLine("[TicketDiagnosticService] MISTRAL_API_KEY not set ticket AI disabled.");
else
_apiKey = apiKey;
Console.WriteLine("[TicketDiagnosticService] initialised.");
}
public static bool IsEnabled => !string.IsNullOrEmpty(_apiKey);
/// <summary>
/// Called fire-and-forget after ticket creation.
/// Creates a TicketAiDiagnosis row (Pending → Analyzing → Completed | Failed).
/// </summary>
public static async Task DiagnoseTicketAsync(Int64 ticketId)
{
var ticket = Db.GetTicketById(ticketId);
if (ticket is null) return;
var installation = Db.GetInstallationById(ticket.InstallationId);
if (installation is null) return;
var diagnosis = new TicketAiDiagnosis
{
TicketId = ticketId,
Status = (Int32)DiagnosisStatus.Pending,
CreatedAt = DateTime.UtcNow
};
Db.Create(diagnosis);
if (!IsEnabled)
{
diagnosis.Status = (Int32)DiagnosisStatus.Failed;
Db.Update(diagnosis);
return;
}
diagnosis.Status = (Int32)DiagnosisStatus.Analyzing;
Db.Update(diagnosis);
try
{
var productName = ((ProductType)installation.Product).ToString();
var recentErrors = Db.Errors
.Where(e => e.InstallationId == ticket.InstallationId)
.OrderByDescending(e => e.Date)
.ToList()
.Select(e => e.Description)
.Distinct()
.Take(5)
.ToList();
var prompt = BuildPrompt(ticket, productName, recentErrors);
var result = await CallMistralAsync(prompt);
if (result is null)
{
diagnosis.Status = (Int32)DiagnosisStatus.Failed;
}
else
{
diagnosis.Status = (Int32)DiagnosisStatus.Completed;
diagnosis.RootCause = result.RootCause;
diagnosis.Confidence = result.Confidence;
diagnosis.RecommendedActions = result.RecommendedActionsJson;
diagnosis.CompletedAt = DateTime.UtcNow;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[TicketDiagnosticService] {ex.Message}");
diagnosis.Status = (Int32)DiagnosisStatus.Failed;
}
Db.Update(diagnosis);
Db.Create(new TicketTimelineEvent
{
TicketId = ticketId,
EventType = (Int32)TimelineEventType.AiDiagnosisAttached,
Description = diagnosis.Status == (Int32)DiagnosisStatus.Completed
? "AI diagnosis completed."
: "AI diagnosis failed.",
ActorType = (Int32)TimelineActorType.AiAgent,
CreatedAt = DateTime.UtcNow
});
}
private static string BuildPrompt(Ticket ticket, string productName, List<string> recentErrors)
{
var recentList = recentErrors.Count > 0
? string.Join(", ", recentErrors)
: "none";
return $@"You are a senior field technician for {productName} battery energy storage systems.
A support ticket has been submitted with the following details:
Subject: {ticket.Subject}
Description: {ticket.Description}
Category: {(TicketCategory)ticket.Category}
Priority: {(TicketPriority)ticket.Priority}
Recent system alarms: {recentList}
Analyze this ticket and respond in JSON only no markdown, no explanation outside JSON:
{{
""rootCause"": ""One concise sentence describing the most likely root cause."",
""confidence"": 0.85,
""recommendedActions"": [""Action 1"", ""Action 2"", ""Action 3""]
}}
Confidence must be a number between 0.0 and 1.0.";
}
private static async Task<TicketDiagnosisResult?> CallMistralAsync(string prompt)
{
try
{
var body = new
{
model = "mistral-small-latest",
messages = new[] { new { role = "user", content = prompt } },
max_tokens = 300,
temperature = 0.2
};
var text = await MistralUrl
.WithHeader("Authorization", $"Bearer {_apiKey}")
.PostJsonAsync(body)
.ReceiveString();
var envelope = JsonConvert.DeserializeObject<dynamic>(text);
var content = (string?)envelope?.choices?[0]?.message?.content;
if (string.IsNullOrWhiteSpace(content)) return null;
var json = content.Trim();
if (json.StartsWith("```"))
{
var nl = json.IndexOf('\n');
if (nl >= 0) json = json[(nl + 1)..];
if (json.EndsWith("```")) json = json[..^3];
json = json.Trim();
}
var parsed = JsonConvert.DeserializeObject<TicketDiagnosisRaw>(json);
if (parsed is null) return null;
return new TicketDiagnosisResult
{
RootCause = parsed.RootCause,
Confidence = parsed.Confidence,
RecommendedActionsJson = JsonConvert.SerializeObject(parsed.RecommendedActions ?? Array.Empty<string>())
};
}
catch (Exception ex)
{
Console.Error.WriteLine($"[TicketDiagnosticService] HTTP error: {ex.Message}");
return null;
}
}
}
internal class TicketDiagnosisRaw
{
[JsonProperty("rootCause")]
public String? RootCause { get; set; }
[JsonProperty("confidence")]
public Double? Confidence { get; set; }
[JsonProperty("recommendedActions")]
public String[]? RecommendedActions { get; set; }
}
internal class TicketDiagnosisResult
{
public String? RootCause { get; set; }
public Double? Confidence { get; set; }
public String? RecommendedActionsJson { get; set; }
}

View File

@ -0,0 +1,178 @@
using Flurl.Http;
using Newtonsoft.Json;
namespace InnovEnergy.App.Backend.Services;
public static class WeatherService
{
public record DailyWeather(
string Date,
double TempMin,
double TempMax,
double SunshineHours,
double PrecipitationMm,
string Description
);
private static readonly Dictionary<string, (double Lat, double Lon)> GeoCache = new();
/// <summary>
/// Returns a 7-day weather forecast for the given city, or null on any failure.
/// </summary>
public static async Task<List<DailyWeather>?> GetForecastAsync(string? city, string? country, string? region = null)
{
if (string.IsNullOrWhiteSpace(city))
return null;
try
{
var coords = await GeocodeAsync(city, region);
if (coords == null)
return null;
var (lat, lon) = coords.Value;
return await FetchForecastAsync(lat, lon);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeatherService] Error fetching forecast for '{city}': {ex.Message}");
return null;
}
}
/// <summary>
/// Formats a forecast list into a compact text block for AI prompt injection.
/// </summary>
public static string FormatForPrompt(List<DailyWeather> forecast)
{
var lines = forecast.Select(d =>
{
var date = DateTime.Parse(d.Date);
var dayName = date.ToString("ddd dd MMM");
return $"- {dayName}: {d.TempMin:F0}{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain";
});
return "WEATHER FORECAST (next 7 days):\n" + string.Join("\n", lines);
}
/// <summary>
/// Extracts a geocodable city name from a Location field that may contain a full address.
/// Handles Swiss formats like "Street 5, 8604 Volketswil" → "Volketswil"
/// Also tries the Region field as fallback.
/// </summary>
private static IEnumerable<string> ExtractSearchTerms(string city, string? region)
{
// If it contains a comma, try the part after the last comma (often "PostalCode City")
if (city.Contains(','))
{
var afterComma = city.Split(',').Last().Trim();
// Strip leading postal code (digits) → "8604 Volketswil" → "Volketswil"
var withoutPostal = System.Text.RegularExpressions.Regex.Replace(afterComma, @"^\d+\s*", "").Trim();
if (!string.IsNullOrEmpty(withoutPostal))
yield return withoutPostal;
if (!string.IsNullOrEmpty(afterComma))
yield return afterComma;
}
// Try the raw value as-is
yield return city;
// Fallback to Region
if (!string.IsNullOrWhiteSpace(region))
yield return region;
}
private static async Task<(double Lat, double Lon)?> GeocodeAsync(string city, string? region = null)
{
if (GeoCache.TryGetValue(city, out var cached))
return cached;
foreach (var term in ExtractSearchTerms(city, region))
{
var url = $"https://geocoding-api.open-meteo.com/v1/search?name={Uri.EscapeDataString(term)}&count=1&language=en";
var json = await url.GetStringAsync();
var data = JsonConvert.DeserializeObject<dynamic>(json);
if (data?.results != null && data.results.Count > 0)
{
var lat = (double)data.results[0].latitude;
var lon = (double)data.results[0].longitude;
GeoCache[city] = (lat, lon);
Console.WriteLine($"[WeatherService] Geocoded '{city}' (matched on '{term}') → ({lat:F4}, {lon:F4})");
return (lat, lon);
}
}
Console.WriteLine($"[WeatherService] Could not geocode location: '{city}'");
return null;
}
private static async Task<List<DailyWeather>?> FetchForecastAsync(double lat, double lon)
{
var url = $"https://api.open-meteo.com/v1/forecast"
+ $"?latitude={lat}&longitude={lon}"
+ "&daily=temperature_2m_max,temperature_2m_min,sunshine_duration,precipitation_sum,weathercode"
+ "&timezone=Europe/Zurich&forecast_days=7";
var json = await url.GetStringAsync();
var data = JsonConvert.DeserializeObject<dynamic>(json);
if (data?.daily == null)
return null;
var dates = data.daily.time;
var tempMax = data.daily.temperature_2m_max;
var tempMin = data.daily.temperature_2m_min;
var sun = data.daily.sunshine_duration;
var precip = data.daily.precipitation_sum;
var codes = data.daily.weathercode;
var forecast = new List<DailyWeather>();
for (int i = 0; i < dates.Count; i++)
{
forecast.Add(new DailyWeather(
Date: (string)dates[i],
TempMin: (double)tempMin[i],
TempMax: (double)tempMax[i],
SunshineHours: Math.Round((double)sun[i] / 3600.0, 1),
PrecipitationMm: (double)precip[i],
Description: WeatherCodeToDescription((int)codes[i])
));
}
Console.WriteLine($"[WeatherService] Fetched {forecast.Count}-day forecast.");
return forecast;
}
private static string WeatherCodeToDescription(int code) => code switch
{
0 => "Clear sky",
1 => "Mainly clear",
2 => "Partly cloudy",
3 => "Overcast",
45 => "Fog",
48 => "Depositing rime fog",
51 => "Light drizzle",
53 => "Moderate drizzle",
55 => "Dense drizzle",
61 => "Slight rain",
63 => "Moderate rain",
65 => "Heavy rain",
66 => "Light freezing rain",
67 => "Heavy freezing rain",
71 => "Slight snow",
73 => "Moderate snow",
75 => "Heavy snow",
77 => "Snow grains",
80 => "Slight showers",
81 => "Moderate showers",
82 => "Violent showers",
85 => "Slight snow showers",
86 => "Heavy snow showers",
95 => "Thunderstorm",
96 => "Thunderstorm with slight hail",
99 => "Thunderstorm with heavy hail",
_ => "Unknown"
};
}

View File

@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using Flurl.Http;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
@ -9,6 +10,54 @@ public static class WeeklyReportService
{
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
/// <summary>
/// Returns xlsx files for the given installation whose date range overlaps [rangeStart, rangeEnd].
/// Filename pattern: {installationId}_MMDD_MMDD.xlsx (e.g. "848_0302_0308.xlsx")
/// Falls back to all files if filenames can't be parsed.
/// </summary>
public static List<string> GetRelevantXlsxFiles(long installationId, DateOnly rangeStart, DateOnly rangeEnd)
{
if (!Directory.Exists(TmpReportDir))
return new List<string>();
var allFiles = Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList();
if (allFiles.Count == 0)
return allFiles;
// Try to filter by filename date range; fall back to all files if parsing fails
var year = rangeStart.Year;
var filtered = new List<string>();
foreach (var file in allFiles)
{
var name = Path.GetFileNameWithoutExtension(file);
// Match pattern: {id}_MMDD_MMDD
var match = Regex.Match(name, @"_(\d{4})_(\d{4})$");
if (!match.Success)
{
// Can't parse filename — include it to be safe
filtered.Add(file);
continue;
}
var startStr = match.Groups[1].Value; // "0302"
var endStr = match.Groups[2].Value; // "0308"
if (!DateOnly.TryParseExact($"{year}-{startStr[..2]}-{startStr[2..]}", "yyyy-MM-dd", out var fileStart) ||
!DateOnly.TryParseExact($"{year}-{endStr[..2]}-{endStr[2..]}", "yyyy-MM-dd", out var fileEnd))
{
filtered.Add(file); // Can't parse — include to be safe
continue;
}
// Include if date ranges overlap
if (fileStart <= rangeEnd && fileEnd >= rangeStart)
filtered.Add(file);
}
return filtered;
}
// ── Calendar Week Helpers ──────────────────────────────────────────
/// <summary>
@ -75,14 +124,13 @@ public static class WeeklyReportService
// 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario)
if (currentWeekDays.Count == 0)
{
var xlsxFiles = Directory.Exists(TmpReportDir)
? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList()
: new List<String>();
// Only parse xlsx files whose date range overlaps the needed weeks
var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
if (xlsxFiles.Count > 0)
if (relevantFiles.Count > 0)
{
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to xlsx.");
var allDaysParsed = xlsxFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
currentWeekDays = allDaysParsed
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
.ToList();
@ -101,9 +149,32 @@ public static class WeeklyReportService
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
.Select(ToHourlyEnergyData).ToList();
// 3b. Fallback: if DB empty, parse hourly data from xlsx
if (currentHourlyData.Count == 0)
{
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
if (relevantFiles.Count > 0)
{
Console.WriteLine($"[WeeklyReportService] Hourly DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
currentHourlyData = relevantFiles
.SelectMany(p => ExcelDataParser.ParseHourly(p))
.Where(h => { var date = DateOnly.FromDateTime(h.DateTime); return date >= curMon && date <= curSun; })
.ToList();
Console.WriteLine($"[WeeklyReportService] Parsed {currentHourlyData.Count} hourly records from xlsx.");
}
}
// 4. Get installation location for weather forecast
var installation = Db.GetInstallationById(installationId);
var location = installation?.Location;
var country = installation?.Country;
var region = installation?.Region;
Console.WriteLine($"[WeeklyReportService] Installation {installationId}: Location='{location}', Region='{region}', Country='{country}', HourlyRecords={currentHourlyData.Count}");
return await GenerateReportFromDataAsync(
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
curMon, curSun);
curMon, curSun, location, country, region);
}
// ── Conversion helpers ─────────────────────────────────────────────
@ -144,7 +215,10 @@ public static class WeeklyReportService
string installationName,
string language = "en",
DateOnly? weekStart = null,
DateOnly? weekEnd = null)
DateOnly? weekEnd = null,
string? location = null,
string? country = null,
string? region = null)
{
currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList();
previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList();
@ -188,7 +262,7 @@ public static class WeeklyReportService
var aiInsight = await GetAiInsightAsync(
currentWeekDays, currentSummary, previousSummary,
selfSufficiency, totalEnergySaved, totalSavingsCHF,
behavior, installationName, language);
behavior, installationName, language, location, country, region);
return new WeeklyReportResponse
{
@ -253,7 +327,10 @@ public static class WeeklyReportService
double totalSavingsCHF,
BehavioralPattern behavior,
string installationName,
string language = "en")
string language = "en",
string? location = null,
string? country = null,
string? region = null)
{
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
@ -262,6 +339,12 @@ public static class WeeklyReportService
return "AI insight unavailable (API key not configured).";
}
// Fetch weather forecast for the installation's location
var forecast = await WeatherService.GetForecastAsync(location, country, region);
var weatherBlock = forecast != null ? "\n" + WeatherService.FormatForPrompt(forecast) + "\n" : "";
Console.WriteLine($"[WeeklyReportService] Weather forecast: {(forecast != null ? $"{forecast.Count} days fetched" : "SKIPPED (no location or API error)")}");
if (forecast != null) Console.WriteLine($"[WeeklyReportService] Weather block:\n{weatherBlock}");
const double ElectricityPriceCHF = 0.39;
// Detect which components are present
@ -278,7 +361,10 @@ public static class WeeklyReportService
var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First();
var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd");
// Behavioral facts as compact lines
// Check if we have meaningful hourly/behavioral data
var hasBehavior = behavior.AvgPeakLoadKwh > 0 || behavior.AvgPeakSolarKwh > 0;
// Behavioral facts as compact lines (only when hourly data exists)
var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "" + FormatHour(behavior.PeakSolarEndHour);
var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0);
@ -303,6 +389,10 @@ public static class WeeklyReportService
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
: "";
// Behavioral section — only include when hourly data exists
var behavioralSection = "";
if (hasBehavior)
{
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" : "";
@ -313,28 +403,69 @@ public static class WeeklyReportService
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
behavioralSection = $@"
BEHAVIORAL PATTERN (from hourly data this week):
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
- {weekdayWeekendLine}{pvBehaviorLines}
{gridBehaviorLine}
{battBehaviorLine}";
}
// 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. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week."
: "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.";
// Instruction 4 — adapts based on whether we have behavioral data
string instruction4;
if (hasBehavior && hasPv)
instruction4 = $"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.";
else if (hasBehavior && hasGrid)
instruction4 = $"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.";
else
instruction4 = "4. Smart action for next week: Based on this week's daily energy patterns, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
// Instruction 5 — weather outlook with pattern-based predictions
var hasWeather = forecast != null;
var bulletCount = hasWeather ? 5 : 4;
var instruction5 = "";
if (hasWeather && hasPv)
{
// Compute avg daily PV production this week for reference
var avgDailyPv = currentWeek.Count > 0 ? Math.Round(currentWeek.Average(d => d.PvProduction), 1) : 0;
var bestDayPv = Math.Round(bestDay.PvProduction, 1);
var worstDayPv = Math.Round(worstDay.PvProduction, 1);
// Classify forecast days by sunshine potential
var sunnyDays = forecast.Where(d => d.SunshineHours >= 7).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
var cloudyDays = forecast.Where(d => d.SunshineHours < 3).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
var totalForecastSunshine = Math.Round(forecast.Sum(d => d.SunshineHours), 1);
var patternContext = $"This week the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
if (sunnyDays.Count > 0)
patternContext += $"Next week, sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
if (cloudyDays.Count > 0)
patternContext += $"Cloudy/rainy days ({string.Join(", ", cloudyDays)}) will likely produce below average (~{worstDayPv} kWh/day or less). ";
patternContext += $"Total forecast sunshine next week: {totalForecastSunshine}h.";
instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days next week and estimate production based on this week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging.";
}
else if (hasWeather)
{
instruction5 = @"5. Weather outlook: Summarize the coming week's weather in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days.";
}
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.
Write {bulletCount} 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.
@ -347,20 +478,16 @@ DAILY FACTS:
{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}
{behavioralSection}
{weatherBlock}
INSTRUCTIONS:
{instruction1}
{instruction2}
{instruction3}
{instruction4}
{instruction5}
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.
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 {bulletCount} bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly {bulletCount} bullet points nothing before, nothing after.
IMPORTANT: Write your entire response in {LanguageName(language)}.";
try
@ -369,7 +496,7 @@ IMPORTANT: Write your entire response in {LanguageName(language)}.";
{
model = "mistral-small-latest",
messages = new[] { new { role = "user", content = prompt } },
max_tokens = 400,
max_tokens = 600,
temperature = 0.3
};

View File

@ -179,8 +179,7 @@ public static class RabbitMqManager
Console.WriteLine("RECEIVED A HEARTBIT FROM prototype, time is "+ WebsocketManager.InstallationConnections[installationId].Timestamp);
}
installation.Status = receivedStatusMessage.Status;
installation.Apply(Db.Update);
Db.UpdateInstallationStatus(installationId, receivedStatusMessage.Status);
//Console.WriteLine("----------------------------------------------");
//If the status has changed, update all the connected front-ends regarding this installation

View File

@ -38,9 +38,7 @@ public static class WebsocketManager
Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp);
installationConnection.Value.Status = (int)StatusType.Offline;
Installation installation = Db.Installations.FirstOrDefault(f => f.Product == installationConnection.Value.Product && f.Id == installationConnection.Key);
installation.Status = (int)StatusType.Offline;
installation.Apply(Db.Update);
Db.UpdateInstallationStatus(installationConnection.Key, (int)StatusType.Offline);
if (installationConnection.Value.Connections.Count > 0)
{
idsToInform.Add(installationConnection.Key);
@ -61,17 +59,31 @@ public static class WebsocketManager
public static async Task InformWebsocketsForInstallation(Int64 installationId)
{
var installation = Db.GetInstallationById(installationId);
if (installation is null) return;
byte[] dataToSend;
List<WebSocket> connections;
lock (InstallationConnections)
{
var installationConnection = InstallationConnections[installationId];
Console.WriteLine("Update all the connected websockets for installation " + installation.Name);
if (!InstallationConnections.ContainsKey(installationId))
{
Console.WriteLine($"InformWebsocketsForInstallation: No entry for installation {installationId}, skipping");
return;
}
// Prune dead/closed connections before sending
var installationConnection = InstallationConnections[installationId];
// Prune dead/closed connections BEFORE checking count
installationConnection.Connections.RemoveAll(c => c.State != WebSocketState.Open);
if (installationConnection.Connections.Count == 0)
{
Console.WriteLine($"InformWebsocketsForInstallation: No open connections for installation {installationId}, skipping");
return;
}
Console.WriteLine("Update all the connected websockets for installation " + installation.Name);
var jsonObject = new
{
id = installationId,

View File

@ -42,6 +42,7 @@
"react-icons": "^4.11.0",
"react-icons-converter": "^1.1.4",
"react-intl": "^6.4.4",
"react-joyride": "^2.9.3",
"react-redux": "^8.1.3",
"react-router": "6.3.0",
"react-router-dom": "6.3.0",
@ -2876,6 +2877,11 @@
"tslib": "^2.4.0"
}
},
"node_modules/@gilbarbara/deep-equal": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz",
"integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw=="
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
@ -8195,6 +8201,12 @@
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
"dev": true
},
"node_modules/deep-diff": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info."
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -8205,7 +8217,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -11465,6 +11476,11 @@
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
"integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g=="
},
"node_modules/is-lite": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-1.2.1.tgz",
"integrity": "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw=="
},
"node_modules/is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
@ -15414,6 +15430,16 @@
"node": ">=4"
}
},
"node_modules/popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@ -17273,6 +17299,41 @@
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
},
"node_modules/react-floater": {
"version": "0.7.9",
"resolved": "https://registry.npmjs.org/react-floater/-/react-floater-0.7.9.tgz",
"integrity": "sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg==",
"dependencies": {
"deepmerge": "^4.3.1",
"is-lite": "^0.8.2",
"popper.js": "^1.16.0",
"prop-types": "^15.8.1",
"tree-changes": "^0.9.1"
},
"peerDependencies": {
"react": "15 - 18",
"react-dom": "15 - 18"
}
},
"node_modules/react-floater/node_modules/@gilbarbara/deep-equal": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz",
"integrity": "sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA=="
},
"node_modules/react-floater/node_modules/is-lite": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-0.8.2.tgz",
"integrity": "sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw=="
},
"node_modules/react-floater/node_modules/tree-changes": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.9.3.tgz",
"integrity": "sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ==",
"dependencies": {
"@gilbarbara/deep-equal": "^0.1.1",
"is-lite": "^0.8.2"
}
},
"node_modules/react-flow-renderer": {
"version": "10.3.17",
"resolved": "https://registry.npmjs.org/react-flow-renderer/-/react-flow-renderer-10.3.17.tgz",
@ -17392,6 +17453,15 @@
"react": ">=16.3.0"
}
},
"node_modules/react-innertext": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz",
"integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==",
"peerDependencies": {
"@types/react": ">=0.0.0 <=99",
"react": ">=0.0.0 <=99"
}
},
"node_modules/react-intl": {
"version": "6.6.8",
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.8.tgz",
@ -17423,6 +17493,44 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"node_modules/react-joyride": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-2.9.3.tgz",
"integrity": "sha512-1+Mg34XK5zaqJ63eeBhqdbk7dlGCFp36FXwsEvgpjqrtyywX2C6h9vr3jgxP0bGHCw8Ilsp/nRDzNVq6HJ3rNw==",
"dependencies": {
"@gilbarbara/deep-equal": "^0.3.1",
"deep-diff": "^1.0.2",
"deepmerge": "^4.3.1",
"is-lite": "^1.2.1",
"react-floater": "^0.7.9",
"react-innertext": "^1.1.5",
"react-is": "^16.13.1",
"scroll": "^3.0.1",
"scrollparent": "^2.1.0",
"tree-changes": "^0.11.2",
"type-fest": "^4.27.0"
},
"peerDependencies": {
"react": "15 - 18",
"react-dom": "15 - 18"
}
},
"node_modules/react-joyride/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-joyride/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-redux": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
@ -18263,6 +18371,16 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
},
"node_modules/scroll": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz",
"integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg=="
},
"node_modules/scrollparent": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
},
"node_modules/select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@ -19771,6 +19889,15 @@
"node": ">=8"
}
},
"node_modules/tree-changes": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.11.3.tgz",
"integrity": "sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag==",
"dependencies": {
"@gilbarbara/deep-equal": "^0.3.1",
"is-lite": "^1.2.1"
}
},
"node_modules/tryer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",
@ -23174,6 +23301,11 @@
"tslib": "^2.4.0"
}
},
"@gilbarbara/deep-equal": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz",
"integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw=="
},
"@humanwhocodes/config-array": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
@ -27055,6 +27187,11 @@
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
"dev": true
},
"deep-diff": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg=="
},
"deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -27064,8 +27201,7 @@
"deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
},
"default-gateway": {
"version": "6.0.3",
@ -29458,6 +29594,11 @@
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
"integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g=="
},
"is-lite": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-1.2.1.tgz",
"integrity": "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw=="
},
"is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
@ -32446,6 +32587,11 @@
}
}
},
"popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ=="
},
"possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@ -33627,6 +33773,39 @@
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
},
"react-floater": {
"version": "0.7.9",
"resolved": "https://registry.npmjs.org/react-floater/-/react-floater-0.7.9.tgz",
"integrity": "sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg==",
"requires": {
"deepmerge": "^4.3.1",
"is-lite": "^0.8.2",
"popper.js": "^1.16.0",
"prop-types": "^15.8.1",
"tree-changes": "^0.9.1"
},
"dependencies": {
"@gilbarbara/deep-equal": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz",
"integrity": "sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA=="
},
"is-lite": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-0.8.2.tgz",
"integrity": "sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw=="
},
"tree-changes": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.9.3.tgz",
"integrity": "sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ==",
"requires": {
"@gilbarbara/deep-equal": "^0.1.1",
"is-lite": "^0.8.2"
}
}
}
},
"react-flow-renderer": {
"version": "10.3.17",
"resolved": "https://registry.npmjs.org/react-flow-renderer/-/react-flow-renderer-10.3.17.tgz",
@ -33725,6 +33904,12 @@
"react": ">=16.3.0"
}
},
"react-innertext": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz",
"integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==",
"requires": {}
},
"react-intl": {
"version": "6.6.8",
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.8.tgz",
@ -33747,6 +33932,36 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"react-joyride": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-2.9.3.tgz",
"integrity": "sha512-1+Mg34XK5zaqJ63eeBhqdbk7dlGCFp36FXwsEvgpjqrtyywX2C6h9vr3jgxP0bGHCw8Ilsp/nRDzNVq6HJ3rNw==",
"requires": {
"@gilbarbara/deep-equal": "^0.3.1",
"deep-diff": "^1.0.2",
"deepmerge": "^4.3.1",
"is-lite": "^1.2.1",
"react-floater": "^0.7.9",
"react-innertext": "^1.1.5",
"react-is": "^16.13.1",
"scroll": "^3.0.1",
"scrollparent": "^2.1.0",
"tree-changes": "^0.11.2",
"type-fest": "^4.27.0"
},
"dependencies": {
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="
}
}
},
"react-redux": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz",
@ -34334,6 +34549,16 @@
}
}
},
"scroll": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz",
"integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg=="
},
"scrollparent": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
},
"select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@ -35520,6 +35745,15 @@
"punycode": "^2.1.1"
}
},
"tree-changes": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.11.3.tgz",
"integrity": "sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag==",
"requires": {
"@gilbarbara/deep-equal": "^0.3.1",
"is-lite": "^1.2.1"
}
},
"tryer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",

View File

@ -38,6 +38,7 @@
"react-icons": "^4.11.0",
"react-icons-converter": "^1.1.4",
"react-intl": "^6.4.4",
"react-joyride": "^2.9.3",
"react-redux": "^8.1.3",
"react-router": "6.3.0",
"react-router-dom": "6.3.0",

View File

@ -22,6 +22,7 @@ import AccessContextProvider from './contexts/AccessContextProvider';
import SalidomoInstallationTabs from './content/dashboards/SalidomoInstallations';
import SodioHomeInstallationTabs from './content/dashboards/SodiohomeInstallations';
import { ProductIdContext } from './contexts/ProductIdContextProvider';
import { TourProvider } from './contexts/TourContext';
function App() {
const context = useContext(UserContext);
@ -127,6 +128,11 @@ function App() {
if (!token) {
return (
<ThemeProvider>
<IntlProvider
messages={getTranslations()}
locale={language}
defaultLocale="en"
>
<CssBaseline />
<Routes>
<Route
@ -143,6 +149,7 @@ function App() {
element={<Navigate to={routes.login}></Navigate>}
></Route>
</Routes>
</IntlProvider>
</ThemeProvider>
);
}
@ -163,6 +170,7 @@ function App() {
locale={language}
defaultLocale="en"
>
<TourProvider>
<InstallationsContextProvider>
<CssBaseline />
<Routes>
@ -237,6 +245,7 @@ function App() {
</Route>
</Routes>
</InstallationsContextProvider>
</TourProvider>
</IntlProvider>
</ThemeProvider>
);

View File

@ -13,7 +13,7 @@ const axiosConfig = axios.create({
axiosConfig.defaults.params = {};
axiosConfig.interceptors.request.use(
(config) => {
const tokenString = localStorage.getItem('token');
const tokenString = localStorage.getItem('token') || sessionStorage.getItem('token');
const token = tokenString !== null ? tokenString : '';
if (token) {
config.params['authToken'] = token;

View File

@ -19,6 +19,7 @@ import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import axiosConfig from 'src/Resources/axiosConfig';
import { useNavigate } from 'react-router-dom';
import routes from 'src/Resources/routes.json';
import { FormattedMessage, useIntl } from 'react-intl';
interface ForgotPasswordPromps {
resetPassword: () => void;
@ -29,6 +30,7 @@ function ForgotPassword() {
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [errorModalOpen, setErrorModalOpen] = useState(false);
const intl = useIntl();
const theme = useTheme();
const context = useContext(UserContext);
@ -105,7 +107,7 @@ function ForgotPassword() {
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Provide your username
<FormattedMessage id="provideYourUsername" defaultMessage="Provide your username" />
</Typography>
<Box
component="form"
@ -118,7 +120,7 @@ function ForgotPassword() {
}}
>
<TextField
label="User Name"
label={intl.formatMessage({ id: 'userName', defaultMessage: 'User Name' })}
variant="outlined"
type="username"
value={username}
@ -150,7 +152,7 @@ function ForgotPassword() {
color="primary"
onClick={handleSubmit}
>
Submit
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
<Modal
@ -176,7 +178,7 @@ function ForgotPassword() {
}}
>
<Typography variant="body1" gutterBottom>
Username is wrong. Please try again.
<FormattedMessage id="usernameWrong" defaultMessage="Username is wrong. Please try again." />
</Typography>
<Button
sx={{
@ -188,7 +190,7 @@ function ForgotPassword() {
}}
onClick={() => setErrorModalOpen(false)}
>
Close
<FormattedMessage id="close" defaultMessage="Close" />
</Button>
</Box>
</Modal>
@ -216,7 +218,7 @@ function ForgotPassword() {
}}
>
<Typography variant="body1" gutterBottom>
Mail sent successfully.
<FormattedMessage id="mailSentSuccessfully" defaultMessage="Mail sent successfully." />
</Typography>
<Button
sx={{
@ -228,7 +230,7 @@ function ForgotPassword() {
}}
onClick={handleReturn}
>
Close
<FormattedMessage id="close" defaultMessage="Close" />
</Button>
</Box>
</Modal>

View File

@ -18,12 +18,13 @@ import { TokenContext } from 'src/contexts/tokenContext';
import { useNavigate } from 'react-router-dom';
import Avatar from '@mui/material/Avatar';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import { FormattedMessage, useIntl } from 'react-intl';
function ResetPassword() {
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [open, setOpen] = useState(false);
const intl = useIntl();
const theme = useTheme();
const context = useContext(UserContext);
const navigate = useNavigate();
@ -102,7 +103,7 @@ function ResetPassword() {
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Reset Password
<FormattedMessage id="resetPassword" defaultMessage="Reset Password" />
</Typography>
<Box
component="form"
@ -115,7 +116,7 @@ function ResetPassword() {
}}
>
<TextField
label="Password"
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
variant="outlined"
type="password"
value={password}
@ -126,7 +127,7 @@ function ResetPassword() {
sx={{ width: 350 }}
/>
<TextField
label="Verify Password"
label={intl.formatMessage({ id: 'verifyPassword', defaultMessage: 'Verify Password' })}
type="password"
variant="outlined"
value={verifypassword}
@ -147,7 +148,7 @@ function ResetPassword() {
variant="h5"
sx={{ color: '#FF0000', marginTop: 1 }}
>
Passwords do not match
<FormattedMessage id="passwordsDoNotMatch" defaultMessage="Passwords do not match" />
</Typography>
)}
@ -164,7 +165,7 @@ function ResetPassword() {
color="primary"
onClick={handleSubmit}
>
Submit
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
<Modal
@ -190,7 +191,7 @@ function ResetPassword() {
}}
>
<Typography variant="body1" gutterBottom>
Reset Password failed. Please try again.
<FormattedMessage id="resetPasswordFailed" defaultMessage="Reset Password failed. Please try again." />
</Typography>
<Button
sx={{
@ -202,7 +203,7 @@ function ResetPassword() {
}}
onClick={() => setOpen(false)}
>
Close
<FormattedMessage id="close" defaultMessage="Close" />
</Button>
</Box>
</Modal>

View File

@ -18,12 +18,13 @@ import { TokenContext } from 'src/contexts/tokenContext';
import { useNavigate } from 'react-router-dom';
import Avatar from '@mui/material/Avatar';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import { FormattedMessage, useIntl } from 'react-intl';
function SetNewPassword() {
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [open, setOpen] = useState(false);
const intl = useIntl();
const theme = useTheme();
const context = useContext(UserContext);
const navigate = useNavigate();
@ -103,7 +104,7 @@ function SetNewPassword() {
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Set New Password
<FormattedMessage id="setNewPassword" defaultMessage="Set New Password" />
</Typography>
<Box
component="form"
@ -116,7 +117,7 @@ function SetNewPassword() {
}}
>
<TextField
label="Password"
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
variant="outlined"
type="password"
value={password}
@ -127,7 +128,7 @@ function SetNewPassword() {
sx={{ width: 350 }}
/>
<TextField
label="Verify Password"
label={intl.formatMessage({ id: 'verifyPassword', defaultMessage: 'Verify Password' })}
type="password"
variant="outlined"
value={verifypassword}
@ -148,7 +149,7 @@ function SetNewPassword() {
variant="h5"
sx={{ color: '#FF0000', marginTop: 1 }}
>
Passwords do not match
<FormattedMessage id="passwordsDoNotMatch" defaultMessage="Passwords do not match" />
</Typography>
)}
@ -165,7 +166,7 @@ function SetNewPassword() {
color="primary"
onClick={handleSubmit}
>
Submit
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
<Modal
@ -191,7 +192,7 @@ function SetNewPassword() {
}}
>
<Typography variant="body1" gutterBottom>
Setting new password failed. Please try again.
<FormattedMessage id="setNewPasswordFailed" defaultMessage="Setting new password failed. Please try again." />
</Typography>
<Button
sx={{
@ -203,7 +204,7 @@ function SetNewPassword() {
}}
onClick={() => setOpen(false)}
>
Close
<FormattedMessage id="close" defaultMessage="Close" />
</Button>
</Box>
</Modal>

View File

@ -17,7 +17,6 @@ import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import Link from '@mui/material/Link';
import inescologo from 'src/Resources/Logo.svg';
import { axiosConfigWithoutToken } from 'src/Resources/axiosConfig';
import Cookies from 'universal-cookie';
import { UserContext } from 'src/contexts/userContext';
import { TokenContext } from 'src/contexts/tokenContext';
import { useNavigate } from 'react-router-dom';
@ -25,6 +24,7 @@ import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import routes from 'src/Resources/routes.json';
import { ProductIdContext } from '../contexts/ProductIdContextProvider';
import { FormattedMessage, useIntl } from 'react-intl';
function Login() {
const [username, setUsername] = useState('');
@ -34,6 +34,7 @@ function Login() {
const [open, setOpen] = useState(false);
const [error, setError] = useState(false);
const intl = useIntl();
const theme = useTheme();
const context = useContext(UserContext);
const {
@ -52,7 +53,6 @@ function Login() {
const { currentUser, setUser, removeUser } = context;
const tokencontext = useContext(TokenContext);
const { token, setNewToken, removeToken } = tokencontext;
const cookies = new Cookies();
const handleUsernameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setUsername(event.target.value);
@ -78,19 +78,14 @@ function Login() {
if (response.data && response.data.token) {
setLoading(false);
setNewToken(response.data.token);
setUser(response.data.user);
setNewToken(response.data.token, rememberMe);
setUser(response.data.user, rememberMe);
setAccessToSalimax(response.data.accessToSalimax);
setAccessToSalidomo(response.data.accessToSalidomo);
setAccessToSodiohome(response.data.accessToSodioHome);
setAccessToSodistore(response.data.accessToSodistoreMax);
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
if (rememberMe) {
cookies.set('rememberedUsername', username, { path: '/' });
cookies.set('rememberedPassword', password, { path: '/' });
}
if (response.data.accessToSalimax) {
navigate(routes.installations);
} else if (response.data.accessToSalidomo) {
@ -147,7 +142,7 @@ function Login() {
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Sign in
<FormattedMessage id="signIn" defaultMessage="Sign in" />
</Typography>
<Box
component="form"
@ -160,7 +155,7 @@ function Login() {
}}
>
<TextField
label="Username"
label={intl.formatMessage({ id: 'username', defaultMessage: 'Username' })}
value={username}
onChange={handleUsernameChange}
fullWidth
@ -176,7 +171,7 @@ function Login() {
/>
<TextField
label="Password"
label={intl.formatMessage({ id: 'password', defaultMessage: 'Password' })}
variant="outlined"
type="password"
value={password}
@ -203,7 +198,7 @@ function Login() {
style={{ marginLeft: -175 }}
/>
}
label="Remember me"
label={<FormattedMessage id="rememberMe" defaultMessage="Remember me" />}
/>
<Button
@ -218,7 +213,7 @@ function Login() {
color="primary"
onClick={handleSubmit}
>
Login
<FormattedMessage id="login" defaultMessage="Login" />
</Button>
{loading && (
@ -253,7 +248,7 @@ function Login() {
}}
>
<Typography variant="body1" gutterBottom>
Login failed. Please try again.
<FormattedMessage id="loginFailed" defaultMessage="Login failed. Please try again." />
</Typography>
<Button
sx={{
@ -265,7 +260,7 @@ function Login() {
}}
onClick={() => setOpen(false)}
>
Close
<FormattedMessage id="close" defaultMessage="Close" />
</Button>
</Box>
</Modal>
@ -281,7 +276,7 @@ function Login() {
onForgotPassword();
}}
>
Forgot password?
<FormattedMessage id="forgotPasswordLink" defaultMessage="Forgot password?" />
</Link>
</Grid>
</Grid>

View File

@ -0,0 +1,117 @@
import { Step } from 'react-joyride';
import { IntlShape } from 'react-intl';
// --- Build a single step with i18n ---
function makeStep(
intl: IntlShape,
target: string,
titleId: string,
contentId: string,
placement: Step['placement'] = 'bottom',
disableBeacon = false
): Step {
return {
target,
title: intl.formatMessage({ id: titleId }),
content: intl.formatMessage({ id: contentId }),
placement,
...(disableBeacon ? { disableBeacon: true } : {})
};
}
// --- Tab key → i18n key mapping ---
const tabConfig: Record<string, { titleId: string; contentId: string }> = {
list: { titleId: 'tourListTitle', contentId: 'tourListContent' },
tree: { titleId: 'tourTreeTitle', contentId: 'tourTreeContent' },
live: { titleId: 'tourLiveTitle', contentId: 'tourLiveContent' },
overview: { titleId: 'tourOverviewTitle', contentId: 'tourOverviewContent' },
batteryview: { titleId: 'tourBatteryviewTitle', contentId: 'tourBatteryviewContent' },
pvview: { titleId: 'tourPvviewTitle', contentId: 'tourPvviewContent' },
log: { titleId: 'tourLogTitle', contentId: 'tourLogContent' },
information: { titleId: 'tourInformationTitle', contentId: 'tourInformationContent' },
report: { titleId: 'tourReportTitle', contentId: 'tourReportContent' },
manage: { titleId: 'tourManageTitle', contentId: 'tourManageContent' },
configuration: { titleId: 'tourConfigurationTitle', contentId: 'tourConfigurationContent' },
history: { titleId: 'tourHistoryTitle', contentId: 'tourHistoryContent' }
};
// Steps to skip inside a specific installation (already covered in the list-page tour)
const listPageOnlyTabs = new Set(['list', 'tree']);
// --- Build tour steps from tab value list ---
function buildTourSteps(intl: IntlShape, tabValues: string[], includeInstallationHint = false, isInsideInstallation = false): Step[] {
const steps: Step[] = [];
if (!isInsideInstallation) {
steps.push(makeStep(intl, '[data-tour="language-selector"]', 'tourLanguageTitle', 'tourLanguageContent', 'bottom', true));
}
for (const value of tabValues) {
if (isInsideInstallation && listPageOnlyTabs.has(value)) continue;
const cfg = tabConfig[value];
if (cfg) {
steps.push(makeStep(intl, `#tour-tab-${value}`, cfg.titleId, cfg.contentId, 'bottom', steps.length === 0));
}
}
if (includeInstallationHint && !isInsideInstallation) {
steps.push(makeStep(intl, '#tour-tab-list', 'tourExploreTitle', 'tourExploreContent'));
}
return steps;
}
// --- Sodistore Home (product 2) ---
export const buildSodiohomeCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'live', 'overview', 'information', 'report'
], false, inside);
export const buildSodiohomePartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'list', 'tree', 'live', 'overview', 'batteryview', 'log', 'information', 'report'
], true, inside);
export const buildSodiohomeAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'list', 'tree', 'live', 'overview', 'batteryview', 'log', 'manage', 'information', 'configuration', 'history', 'report'
], true, inside);
// --- Salimax (product 0) / Sodistore Max (product 3) ---
export const buildSalimaxCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'live', 'overview', 'information'
], false, inside);
export const buildSalimaxPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'list', 'tree', 'live', 'overview', 'batteryview', 'pvview', 'information'
], true, inside);
export const buildSalimaxAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'list', 'tree', 'live', 'overview', 'batteryview', 'manage', 'log', 'information', 'configuration', 'history', 'pvview'
], true, inside);
// --- Sodistore Grid (product 4) — same as Salimax but no PV View ---
export const buildSodistoregridCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'live', 'overview', 'information'
], false, inside);
export const buildSodistoregridPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'list', 'tree', 'live', 'overview', 'batteryview', 'information'
], true, inside);
export const buildSodistoregridAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'list', 'tree', 'live', 'overview', 'batteryview', 'manage', 'log', 'information', 'configuration', 'history'
], true, inside);
// --- Salidomo (product 1) ---
export const buildSalidomoCustomerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'batteryview', 'overview', 'information'
], false, inside);
export const buildSalidomoPartnerTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'list', 'tree', 'batteryview', 'overview', 'information'
], true, inside);
export const buildSalidomoAdminTourSteps = (intl: IntlShape, inside = false) => buildTourSteps(intl, [
'list', 'tree', 'batteryview', 'overview', 'log', 'manage', 'information', 'history'
], true, inside);

View File

@ -87,10 +87,10 @@ function BatteryView(props: BatteryViewProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Unable to communicate with the installation
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
</Typography>
</Container>
)}
@ -111,10 +111,10 @@ function BatteryView(props: BatteryViewProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Battery service is not available at the moment
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
</Typography>
</Container>
)}
@ -229,24 +229,24 @@ function BatteryView(props: BatteryViewProps) {
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell align="center">Battery</TableCell>
<TableCell align="center">Firmware</TableCell>
<TableCell align="center">Power</TableCell>
<TableCell align="center">Battery Voltage</TableCell>
<TableCell align="center">SoC</TableCell>
<TableCell align="center">Temperature</TableCell>
<TableCell align="center"><FormattedMessage id="battery" defaultMessage="Battery" /></TableCell>
<TableCell align="center"><FormattedMessage id="firmware" defaultMessage="Firmware" /></TableCell>
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
<TableCell align="center"><FormattedMessage id="batteryVoltage" defaultMessage="Battery Voltage" /></TableCell>
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
<TableCell align="center"><FormattedMessage id="temperature" defaultMessage="Temperature" /></TableCell>
{product === 0 ? (
<TableCell align="center">Warnings</TableCell>
<TableCell align="center"><FormattedMessage id="warnings" defaultMessage="Warnings" /></TableCell>
) : (
<TableCell align="center">Min Cell Voltage</TableCell>
<TableCell align="center"><FormattedMessage id="minCellVoltage" defaultMessage="Min Cell Voltage" /></TableCell>
)}
{product === 0 ? (
<TableCell align="center">Alarms</TableCell>
<TableCell align="center"><FormattedMessage id="alarms" defaultMessage="Alarms" /></TableCell>
) : (
<TableCell align="center">Max Cell Voltage</TableCell>
<TableCell align="center"><FormattedMessage id="maxCellVoltage" defaultMessage="Max Cell Voltage" /></TableCell>
)}
{(product === 3 || product === 4) && (
<TableCell align="center">Voltage Difference</TableCell>
<TableCell align="center"><FormattedMessage id="voltageDifference" defaultMessage="Voltage Difference" /></TableCell>
)}
</TableRow>
</TableHead>

View File

@ -85,10 +85,10 @@ function BatteryViewSalidomo(props: BatteryViewProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Unable to communicate with the installation
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
</Typography>
</Container>
)}
@ -109,10 +109,10 @@ function BatteryViewSalidomo(props: BatteryViewProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Battery service is not available at the moment
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
</Typography>
</Container>
)}
@ -209,14 +209,14 @@ function BatteryViewSalidomo(props: BatteryViewProps) {
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell align="center">Battery</TableCell>
<TableCell align="center">Firmware</TableCell>
<TableCell align="center">Power</TableCell>
<TableCell align="center">Voltage</TableCell>
<TableCell align="center">SoC</TableCell>
<TableCell align="center">Temperature</TableCell>
<TableCell align="center">Warnings</TableCell>
<TableCell align="center">Alarms</TableCell>
<TableCell align="center"><FormattedMessage id="battery" defaultMessage="Battery" /></TableCell>
<TableCell align="center"><FormattedMessage id="firmware" defaultMessage="Firmware" /></TableCell>
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
<TableCell align="center"><FormattedMessage id="voltage" defaultMessage="Voltage" /></TableCell>
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
<TableCell align="center"><FormattedMessage id="temperature" defaultMessage="Temperature" /></TableCell>
<TableCell align="center"><FormattedMessage id="warnings" defaultMessage="Warnings" /></TableCell>
<TableCell align="center"><FormattedMessage id="alarms" defaultMessage="Alarms" /></TableCell>
</TableRow>
</TableHead>
<TableBody>

View File

@ -38,20 +38,41 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
const navigate = useNavigate();
const inverter = (props.values as any)?.InverterRecord;
const batteryClusterNumber = props.installation.batteryClusterNumber;
const hasDevices = !!inverter?.Devices;
const sortedBatteryView = inverter
? Array.from({ length: batteryClusterNumber }, (_, i) => {
const index = i + 1; // Battery1, Battery2, ...
if (hasDevices) {
// Sinexcel: map across devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, 3→D2/B2
const deviceId = String(Math.floor(i / 2) + 1);
const batteryIndex = (i % 2) + 1;
const device = inverter.Devices[deviceId];
return {
BatteryId: String(i + 1),
battery: {
Voltage: device?.[`Battery${batteryIndex}PackTotalVoltage`] ?? 0,
Current: device?.[`Battery${batteryIndex}PackTotalCurrent`] ?? 0,
Power: device?.[`Battery${batteryIndex}Power`] ?? 0,
Soc: device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0,
Soh: device?.[`Battery${batteryIndex}Soh`] ?? 0,
}
};
} else {
// Growatt: flat Battery1, Battery2, ...
const index = i + 1;
return {
BatteryId: String(index),
battery: {
Voltage: inverter[`Battery${index}Voltage`],
Current: inverter[`Battery${index}Current`],
Power: inverter[`Battery${index}Power`],
Soc: inverter[`Battery${index}Soc`],
Soh: inverter[`Battery${index}Soh`],
Voltage: inverter[`Battery${index}Voltage`] ?? 0,
Current: inverter[`Battery${index}Current`] ?? 0,
Power: inverter[`Battery${index}Power`] ?? 0,
Soc: inverter[`Battery${index}Soc`] ?? 0,
Soh: inverter[`Battery${index}Soh`] ?? 0,
}
};
}
})
: [];
@ -87,10 +108,10 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Unable to communicate with the installation
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
</Typography>
</Container>
)}
@ -111,10 +132,10 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Battery service is not available at the moment
<FormattedMessage id="batteryServiceNotAvailable" defaultMessage="Battery service is not available at the moment" />
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
</Typography>
</Container>
)}
@ -195,12 +216,12 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell align="center">Battery</TableCell>
<TableCell align="center">Power</TableCell>
<TableCell align="center">Battery Voltage</TableCell>
<TableCell align="center">Current</TableCell>
<TableCell align="center">SoC</TableCell>
<TableCell align="center">SoH</TableCell>
<TableCell align="center"><FormattedMessage id="battery" defaultMessage="Battery" /></TableCell>
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
<TableCell align="center"><FormattedMessage id="batteryVoltage" defaultMessage="Battery Voltage" /></TableCell>
<TableCell align="center"><FormattedMessage id="current" defaultMessage="Current" /></TableCell>
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
<TableCell align="center"><FormattedMessage id="soh" defaultMessage="SoH" /></TableCell>
{/*<TableCell align="center">Daily Charge Energy</TableCell>*/}
{/*<TableCell align="center">Daily Discharge Energy</TableCell>*/}
</TableRow>

View File

@ -634,66 +634,66 @@ function DetailedBatteryViewSodistore(
{/*----------------------------------------------------------------------------------------------------------------------------------*/}
{/*Temperature List*/}
<Card
sx={{
overflow: 'visible',
marginTop: '20px',
marginLeft: '20px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
border: '2px solid #ccc',
borderRadius: '12px',
height: '270px'
}}
>
<Typography
variant="h6"
component="div"
sx={{
marginTop: '10px',
borderBottom: '1px solid #ccc',
fontWeight: 'bold'
}}
>
Battery Temperatures
</Typography>
{/*<Card*/}
{/* sx={{*/}
{/* overflow: 'visible',*/}
{/* marginTop: '20px',*/}
{/* marginLeft: '20px',*/}
{/* display: 'flex',*/}
{/* flexDirection: 'column',*/}
{/* alignItems: 'center',*/}
{/* border: '2px solid #ccc',*/}
{/* borderRadius: '12px',*/}
{/* height: '270px'*/}
{/* }}*/}
{/*>*/}
{/* <Typography*/}
{/* variant="h6"*/}
{/* component="div"*/}
{/* sx={{*/}
{/* marginTop: '10px',*/}
{/* borderBottom: '1px solid #ccc',*/}
{/* fontWeight: 'bold'*/}
{/* }}*/}
{/* >*/}
{/* Battery Temperatures*/}
{/* </Typography>*/}
<TableContainer
component={Paper}
sx={{
marginTop: '20px',
width: '100%',
maxHeight: '270px', // scrolling threshold
overflowY: 'auto'
}}
>
<Table size="medium" stickyHeader aria-label="temperature table">
<TableBody>
{Object.entries(
props.batteryData.BatteryDeligreenDataRecord
.TemperaturesList || {}
).map(([label, value]) => (
<TableRow key={label}>
<TableCell align="left" sx={{ fontWeight: 'bold' }}>
{label}
</TableCell>
<TableCell
align="right"
sx={{
width: '6ch',
whiteSpace: 'nowrap',
paddingRight: '12px'
}}
>
{value + ' °C'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Card>
{/* <TableContainer*/}
{/* component={Paper}*/}
{/* sx={{*/}
{/* marginTop: '20px',*/}
{/* width: '100%',*/}
{/* maxHeight: '270px',*/}
{/* overflowY: 'auto'*/}
{/* }}*/}
{/* >*/}
{/* <Table size="medium" stickyHeader aria-label="temperature table">*/}
{/* <TableBody>*/}
{/* {Object.entries(*/}
{/* props.batteryData.BatteryDeligreenDataRecord*/}
{/* .TemperaturesList || {}*/}
{/* ).map(([label, value]) => (*/}
{/* <TableRow key={label}>*/}
{/* <TableCell align="left" sx={{ fontWeight: 'bold' }}>*/}
{/* {label}*/}
{/* </TableCell>*/}
{/* <TableCell*/}
{/* align="right"*/}
{/* sx={{*/}
{/* width: '6ch',*/}
{/* whiteSpace: 'nowrap',*/}
{/* paddingRight: '12px'*/}
{/* }}*/}
{/* >*/}
{/* {value + ' °C'}*/}
{/* </TableCell>*/}
{/* </TableRow>*/}
{/* ))}*/}
{/* </TableBody>*/}
{/* </Table>*/}
{/* </TableContainer>*/}
{/*</Card>*/}
</Grid>
{/*----------------------------------------------------------------------------------------------------------------------------------*/}

View File

@ -295,7 +295,7 @@ function MainStats(props: MainStatsProps) {
>
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
Fetching data...
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
</Typography>
</Container>
)}

View File

@ -294,7 +294,7 @@ function MainStatsSalidomo(props: MainStatsProps) {
>
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
Fetching data...
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
</Typography>
</Container>
)}

View File

@ -302,7 +302,7 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
>
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
Fetching data...
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
</Typography>
</Container>
)}

View File

@ -19,7 +19,7 @@ import {
} from '@mui/material';
import React, { useContext, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import MenuItem from '@mui/material/MenuItem';
@ -40,6 +40,7 @@ interface ConfigurationProps {
}
function Configuration(props: ConfigurationProps) {
const intl = useIntl();
if (props.values === null) {
return null;
}
@ -137,7 +138,7 @@ function Configuration(props: ConfigurationProps) {
props.values.EssControl.Mode === 'CalibrationCharge'
) {
setDateSelectionError(
'You cannot change the date while the installation is in Calibration Charge Mode'
intl.formatMessage({ id: 'cannotChangeDateCalibration' })
);
setErrorDateModalOpen(true);
return;
@ -146,7 +147,7 @@ function Configuration(props: ConfigurationProps) {
dayjs(formValues.calibrationChargeDate).isBefore(dayjs())
) {
//console.log('asked for', dayjs(formValues.calibrationChargeDate));
setDateSelectionError('You must specify a future date');
setDateSelectionError(intl.formatMessage({ id: 'mustSpecifyFutureDate' }));
setErrorDateModalOpen(true);
return;
} else {
@ -458,7 +459,7 @@ function Configuration(props: ConfigurationProps) {
helperText={
errors.minimumSoC ? (
<span style={{ color: 'red' }}>
Value should be between 0-100%
{intl.formatMessage({ id: 'valueBetween0And100' })}
</span>
) : (
''
@ -592,7 +593,7 @@ function Configuration(props: ConfigurationProps) {
helperText={
errors.gridSetPoint ? (
<span style={{ color: 'red' }}>
Please provide a valid number
{intl.formatMessage({ id: 'pleaseProvideValidNumber' })}
</span>
) : (
''
@ -804,7 +805,7 @@ function Configuration(props: ConfigurationProps) {
alignItems: 'center'
}}
>
Successfully applied configuration file
<FormattedMessage id="successfullyAppliedConfig" defaultMessage="Successfully applied configuration file" />
<IconButton
color="inherit"
size="small"
@ -824,7 +825,7 @@ function Configuration(props: ConfigurationProps) {
alignItems: 'center'
}}
>
An error has occurred
<FormattedMessage id="configErrorOccurred" defaultMessage="An error has occurred" />
<IconButton
color="inherit"
size="small"

View File

@ -30,6 +30,8 @@ import timezone from 'dayjs/plugin/timezone';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { UserContext } from '../../../contexts/userContext';
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
dayjs.extend(utc);
dayjs.extend(timezone);
@ -58,6 +60,8 @@ function HistoryOfActions(props: HistoryProps) {
});
const context = useContext(UserContext);
const { currentUser, setUser } = context;
const { fetchAllInstallations } = useContext(InstallationsContext);
const { product } = useContext(ProductIdContext);
const [isRowHovered, setHoveredRow] = useState(-1);
const [selectedAction, setSelectedAction] = useState<number>(-1);
const [editMode, setEditMode] = useState(false);
@ -109,6 +113,7 @@ function HistoryOfActions(props: HistoryProps) {
if (res) {
getHistory();
fetchAllInstallations(product, false);
setOpenModalAddAction(false);
setEditMode(false);
}
@ -129,6 +134,7 @@ function HistoryOfActions(props: HistoryProps) {
if (res) {
getHistory();
fetchAllInstallations(product, false);
}
};

View File

@ -1,5 +1,6 @@
import {
Alert,
Autocomplete,
Box,
CardContent,
CircularProgress,
@ -14,13 +15,14 @@ import {
import { FormattedMessage } from 'react-intl';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import React, { useContext, useState } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import { I_S3Credentials } from '../../../interfaces/S3Types';
import { I_Installation } from '../../../interfaces/InstallationTypes';
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
import { UserContext } from '../../../contexts/userContext';
import { UserType } from '../../../interfaces/UserTypes';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
import axiosConfig from '../../../Resources/axiosConfig';
import { useLocation, useNavigate } from 'react-router-dom';
@ -53,6 +55,24 @@ function Information(props: InformationProps) {
deleteInstallation
} = installationContext;
const canEdit = currentUser.userType == UserType.admin;
const isPartner = currentUser.userType == UserType.partner;
const isSodistore = formValues.product === 3 || formValues.product === 4;
const [networkProviders, setNetworkProviders] = useState<string[]>([]);
const [loadingProviders, setLoadingProviders] = useState(false);
useEffect(() => {
if (isSodistore) {
setLoadingProviders(true);
axiosConfig
.get('/GetNetworkProviders')
.then((res) => setNetworkProviders(res.data))
.catch(() => {})
.finally(() => setLoadingProviders(false));
}
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setFormValues({
@ -286,6 +306,54 @@ function Information(props: InformationProps) {
error={formValues.country === ''}
/>
</div>
{isSodistore && (
<div>
<Autocomplete
freeSolo
options={networkProviders}
value={formValues.networkProvider || ''}
onChange={(_e, val) =>
setFormValues({
...formValues,
networkProvider: (val as string) || ''
})
}
onInputChange={(_e, val) =>
setFormValues({
...formValues,
networkProvider: val || ''
})
}
disabled={!canEdit && !isPartner}
loading={loadingProviders}
renderInput={(params) => (
<TextField
{...params}
label={
<FormattedMessage
id="networkProvider"
defaultMessage="Network Provider"
/>
}
variant="outlined"
fullWidth
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loadingProviders ? (
<CircularProgress size={20} />
) : null}
{params.InputProps.endAdornment}
</>
)
}}
/>
)}
/>
</div>
)}
<div>
<TextField
label={
@ -341,7 +409,7 @@ function Information(props: InformationProps) {
/>
</div>
{currentUser.userType == UserType.admin && (
{canEdit && (
<>
<div>
<TextField
@ -400,7 +468,7 @@ function Information(props: InformationProps) {
marginTop: 10
}}
>
{currentUser.userType == UserType.admin && (
{canEdit && (
<Button
variant="contained"
onClick={handleDelete}
@ -414,7 +482,7 @@ function Information(props: InformationProps) {
/>
</Button>
)}
{currentUser.userType == UserType.admin && (
{(canEdit || (isPartner && isSodistore)) && (
<Button
variant="contained"
onClick={handleSubmit}

View File

@ -1,5 +1,6 @@
import {
Alert,
Autocomplete,
Box,
CardContent,
CircularProgress,
@ -26,6 +27,7 @@ import { UserContext } from '../../../contexts/userContext';
import routes from '../../../Resources/routes.json';
import { useNavigate } from 'react-router-dom';
import { UserType } from '../../../interfaces/UserTypes';
import axiosConfig from '../../../Resources/axiosConfig';
interface InformationSodistorehomeProps {
values: I_Installation;
@ -178,6 +180,18 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
const canEdit = currentUser.userType === UserType.admin;
const isPartner = currentUser.userType === UserType.partner;
const [networkProviders, setNetworkProviders] = useState<string[]>([]);
const [loadingProviders, setLoadingProviders] = useState(false);
useEffect(() => {
setLoadingProviders(true);
axiosConfig
.get('/GetNetworkProviders')
.then((res) => setNetworkProviders(res.data))
.catch(() => {})
.finally(() => setLoadingProviders(false));
}, []);
return (
<>
{openModalDeleteInstallation && (
@ -361,6 +375,52 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
/>
</div>
<div>
<Autocomplete
freeSolo
options={networkProviders}
value={formValues.networkProvider || ''}
onChange={(_e, val) =>
setFormValues({
...formValues,
networkProvider: (val as string) || ''
})
}
onInputChange={(_e, val) =>
setFormValues({
...formValues,
networkProvider: val || ''
})
}
disabled={!canEdit && !isPartner}
loading={loadingProviders}
renderInput={(params) => (
<TextField
{...params}
label={
<FormattedMessage
id="networkProvider"
defaultMessage="Network Provider"
/>
}
variant="outlined"
fullWidth
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loadingProviders ? (
<CircularProgress size={20} />
) : null}
{params.InputProps.endAdornment}
</>
)
}}
/>
)}
/>
</div>
{canEdit && (
<div>
<TextField

View File

@ -22,7 +22,7 @@ import {
import { I_Installation } from 'src/interfaces/InstallationTypes';
import CancelIcon from '@mui/icons-material/Cancel';
import BuildIcon from '@mui/icons-material/Build';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { useLocation, useNavigate } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
@ -33,6 +33,7 @@ interface FlatInstallationViewProps {
}
const FlatInstallationView = (props: FlatInstallationViewProps) => {
const intl = useIntl();
const [isRowHovered, setHoveredRow] = useState(-1);
const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
@ -202,7 +203,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
>
<FormControl sx={{ flex: 1 }}>
<TextField
placeholder="Search"
placeholder={intl.formatMessage({ id: 'search' })}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
fullWidth
@ -226,7 +227,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<Select
value={sortByStatus}
onChange={(e) => setSortByStatus(e.target.value)}
label="Show Only"
label={intl.formatMessage({ id: 'showOnly' })}
>
{[
'All Installations',
@ -252,7 +253,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<Select
value={sortByAction}
onChange={(e) => setSortByAction(e.target.value)}
label="Show Only"
label={intl.formatMessage({ id: 'showOnly' })}
>
{[
'All Installations',

View File

@ -200,6 +200,7 @@ function Installation(props: singleInstallationProps) {
currentTab == 'live' ||
currentTab == 'pvview' ||
currentTab == 'configuration' ||
currentTab == 'overview' ||
location.includes('batteryview')
) {
//Fetch periodically if the tab is live, pvview or batteryview
@ -217,6 +218,10 @@ function Installation(props: singleInstallationProps) {
}
}
}
// Fetch one time in overview tab to determine connectivity
if (currentTab == 'overview') {
fetchDataForOneTime();
}
//Fetch only one time in configuration tab
if (currentTab == 'configuration') {
fetchDataForOneTime();
@ -376,7 +381,6 @@ function Installation(props: singleInstallationProps) {
currentTab != 'information' &&
currentTab != 'history' &&
currentTab != 'manage' &&
currentTab != 'overview' &&
currentTab != 'log' && (
<Container
maxWidth="xl"
@ -394,7 +398,7 @@ function Installation(props: singleInstallationProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Connecting to the device...
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
</Typography>
</Container>
)}
@ -447,6 +451,8 @@ function Installation(props: singleInstallationProps) {
<Overview
s3Credentials={s3Credentials}
id={props.current_installation.id}
connected={connected}
loading={loading}
></Overview>
}
/>

View File

@ -5,6 +5,66 @@ import { S3Access } from 'src/dataCache/S3/S3Access';
import { JSONRecordData, parseChunkJson } from '../Log/graph.util';
import JSZip from 'jszip';
// Find the latest chunk file in S3 using ListObjects (single HTTP request)
// Returns the parsed chunk data or FetchResult.notAvailable
export const fetchLatestDataJson = (
s3Credentials?: I_S3Credentials,
maxAgeSeconds: number = 400
): Promise<FetchResult<Record<string, JSONRecordData>>> => {
if (!s3Credentials || !s3Credentials.s3Bucket) {
return Promise.resolve(FetchResult.notAvailable);
}
const s3Access = new S3Access(
s3Credentials.s3Bucket,
s3Credentials.s3Region,
s3Credentials.s3Provider,
s3Credentials.s3Key,
s3Credentials.s3Secret
);
// Use marker to skip files older than maxAgeSeconds
const oldestTimestamp = Math.floor(Date.now() / 1000) - maxAgeSeconds;
const marker = `${oldestTimestamp}.json`;
return s3Access
.list(marker, 50)
.then(async (r) => {
if (r.status !== 200) {
return Promise.resolve(FetchResult.notAvailable);
}
const xml = await r.text();
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'application/xml');
const keys = Array.from(doc.getElementsByTagName('Key'))
.map((el) => el.textContent)
.filter((key) => key && /^\d+\.json$/.test(key))
.sort((a, b) => Number(b.replace('.json', '')) - Number(a.replace('.json', '')));
if (keys.length === 0) {
return Promise.resolve(FetchResult.notAvailable);
}
// Fetch the most recent chunk file
const latestKey = keys[0];
const res = await s3Access.get(latestKey);
if (res.status !== 200) {
return Promise.resolve(FetchResult.notAvailable);
}
const jsontext = await res.text();
const byteArray = Uint8Array.from(atob(jsontext), (c) =>
c.charCodeAt(0)
);
const zip = await JSZip.loadAsync(byteArray);
const jsonContent = await zip.file('data.json').async('text');
return parseChunkJson(jsonContent);
})
.catch(() => {
return Promise.resolve(FetchResult.tryLater);
});
};
export const fetchDataJson = (
timestamp: UnixTime,
s3Credentials?: I_S3Credentials,
@ -50,7 +110,8 @@ export const fetchDataJson = (
export const fetchAggregatedDataJson = (
date: string,
s3Credentials?: I_S3Credentials
s3Credentials?: I_S3Credentials,
product?: number
): Promise<FetchResult<any>> => {
const s3Path = `${date}.json`;
@ -68,7 +129,12 @@ export const fetchAggregatedDataJson = (
if (r.status === 404) {
return Promise.resolve(FetchResult.notAvailable);
} else if (r.status === 200) {
const jsontext = await r.text(); // Assuming the server returns the Base64 encoded ZIP file as text
const jsontext = await r.text();
if (product === 2) {
return parseSinexcelAggregatedData(jsontext);
}
const contentEncoding = r.headers.get('content-type');
if (contentEncoding != 'application/base64; charset=utf-8') {
@ -82,7 +148,6 @@ export const fetchAggregatedDataJson = (
const zip = await JSZip.loadAsync(byteArray);
// Assuming the CSV file is named "data.csv" inside the ZIP archive
const jsonContent = await zip.file('data.json').async('text');
//console.log(jsonContent);
return JSON.parse(jsonContent);
} else {
return Promise.resolve(FetchResult.notAvailable);
@ -94,6 +159,24 @@ export const fetchAggregatedDataJson = (
}
};
const parseSinexcelAggregatedData = (jsontext: string): any => {
const lines = jsontext.trim().split('\n');
for (const line of lines) {
const entry = JSON.parse(line);
if (entry.Type === 'Daily') {
return {
PvPower: entry.DailySelfGeneratedElectricity ?? 0,
GridImportPower: entry.DailyElectricityPurchased ?? 0,
GridExportPower: -(entry.DailyElectricityFed ?? 0),
ChargingBatteryPower: entry.BatteryDailyChargeEnergy ?? 0,
DischargingBatteryPower: -(entry.BatteryDailyDischargeEnergy ?? 0),
LoadPowerConsumption: entry.DailyLoadPowerConsumption ?? 0
};
}
}
return FetchResult.notAvailable;
};
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------
//--------------------------------------------------------------------------------------------------------------------------------------------------------------------

View File

@ -412,6 +412,7 @@ function InstallationTabs(props: InstallationTabsProps) {
? routes[tab.value]
: navigateToTabPath(location.pathname, routes[tab.value])
}
id={`tour-tab-${tab.value}`}
/>
))}
</Tabs>
@ -480,6 +481,7 @@ function InstallationTabs(props: InstallationTabsProps) {
component={Link}
label={tab.label}
to={routes[tab.value]}
id={`tour-tab-${tab.value}`}
/>
))}
</Tabs>

View File

@ -259,7 +259,8 @@ function Log(props: LogProps) {
<Container maxWidth="xl">
<Grid container>
{/* ── AI Diagnosis Demo Panel ── */}
{/* ── AI Diagnosis Demo Panel (commented out — using live AI diagnosis only) ── */}
{/*
<Grid item xs={12}>
<Box sx={{ marginTop: '20px' }}>
<Button
@ -355,6 +356,7 @@ function Log(props: LogProps) {
</Card>
)}
</Grid>
*/}
{/* AI Diagnosis banner — shown when loading or diagnoses are available */}
{diagnosisLoading && (

View File

@ -437,23 +437,76 @@ export interface JSONRecordData {
};
};
// For SodistoreHome
// For SodistoreHome (Sinexcel multi-inverter structure)
InverterRecord: {
GridPower:number;
Battery1Power:number;
Battery1Soc:number;
Battery1Soh:number;
Battery1Voltage:number;
Battery1Current:number;
Battery2Power:number;
Battery2Soc:number;
Battery2Voltage:number;
Battery2Current:number;
Battery2Soh:number;
PvPower:number;
ConsumptionPower:number;
WorkingMode?:string;
OperatingMode?:string;
// Top-level aggregated values
TotalPhotovoltaicPower: number;
TotalBatteryPower: number;
TotalLoadPower: number;
TotalGridPower: number;
AvgBatteryVoltage: number;
TotalBatteryCurrent: number;
AvgBatterySoc: number;
AvgBatterySoh: number;
AvgBatteryTemp: number;
OperatingPriority?: string;
MinSoc: number;
MaxChargeCurrent: number;
MaxDischargingCurrent: number;
GridPower: number;
GridFrequency: number;
InverterPower: number;
EnableGridExport?: string;
GridExportPower: number;
// Legacy flat fields (Growatt compatibility)
Battery1Power?: number;
Battery1Soc?: number;
Battery1Soh?: number;
Battery1Voltage?: number;
Battery1Current?: number;
Battery2Power?: number;
Battery2Soc?: number;
Battery2Voltage?: number;
Battery2Current?: number;
Battery2Soh?: number;
PvPower?: number;
ConsumptionPower?: number;
WorkingMode?: string;
OperatingMode?: string;
PvTotalPower?: number;
Battery1AmbientTemperature?: number;
Battery1Temperature?: number;
// Per-device records (Sinexcel multi-inverter)
Devices?: {
[deviceId: string]: {
Battery1Power: number;
Battery1Soc: number;
Battery1Soh: number;
Battery1Voltage: number;
Battery1Current: number;
Battery1PackTotalVoltage: number;
Battery1PackTotalCurrent: number;
Battery1Temperature: number;
Battery1SocSecondvalue: number;
Battery2Power: number;
Battery2Soc: number;
Battery2Soh: number;
Battery2Voltage: number;
Battery2Current: number;
Battery2PackTotalVoltage: number;
Battery2PackTotalCurrent: number;
Battery2Temperature: number;
Battery2Socsecondvalue: number;
ConsumptionPower: number;
TotalPhotovoltaicPower: number;
TotalBatteryPower: number;
TotalLoadPower: number;
TotalGridPower: number;
GridPower: number;
[key: string]: any;
};
};
[key: string]: any;
};
AcDcGrowatt: {

View File

@ -19,7 +19,7 @@ import {
transformInputToDailyDataJson
} from 'src/interfaces/Chart';
import Button from '@mui/material/Button';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import CircularProgress from '@mui/material/CircularProgress';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
@ -33,6 +33,9 @@ import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
interface OverviewProps {
s3Credentials: I_S3Credentials;
id: number;
device?: number;
connected?: boolean;
loading?: boolean;
}
const computeLast7Days = (): string[] => {
@ -52,6 +55,7 @@ const computeLast7Days = (): string[] => {
};
function Overview(props: OverviewProps) {
const intl = useIntl();
const context = useContext(UserContext);
const { currentUser } = context;
const [dailyData, setDailyData] = useState(true);
@ -102,6 +106,12 @@ function Overview(props: OverviewProps) {
}
}, [isZooming, dailyDataArray]);
useEffect(() => {
if (props.connected === false) {
setErrorDateModalOpen(false);
}
}, [props.connected]);
useEffect(() => {
const resultPromise: Promise<{
chartData: chartDataInterface;
@ -119,8 +129,6 @@ function Overview(props: OverviewProps) {
resultPromise
.then((result) => {
if (result.chartData.soc.data.length === 0) {
setDateSelectionError('No data available for the selected date range. Please choose a more recent date.');
setErrorDateModalOpen(true);
setLoading(false);
return;
}
@ -209,11 +217,19 @@ function Overview(props: OverviewProps) {
}> = transformInputToAggregatedDataJson(
props.s3Credentials,
dayjs().subtract(1, 'week'),
dayjs()
dayjs(),
product
);
resultPromise
.then((result) => {
if (result.dateList.length === 0) {
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
setErrorDateModalOpen(true);
setLoading(false);
return;
}
const powerDifference = [];
for (
let i = 0;
@ -288,7 +304,7 @@ function Overview(props: OverviewProps) {
resultPromise
.then((result) => {
if (result.chartData.soc.data.length === 0) {
setDateSelectionError('No data available for the selected date range. Please choose a more recent date.');
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
setErrorDateModalOpen(true);
setLoading(false);
return;
@ -319,11 +335,19 @@ function Overview(props: OverviewProps) {
}> = transformInputToAggregatedDataJson(
props.s3Credentials,
startDate,
endDate
endDate,
product
);
resultPromise
.then((result) => {
if (result.dateList.length === 0) {
setDateSelectionError(intl.formatMessage({ id: 'noDataForDateRange' }));
setErrorDateModalOpen(true);
setLoading(false);
return;
}
const powerDifference = [];
for (
@ -379,6 +403,23 @@ function Overview(props: OverviewProps) {
const renderGraphs = () => {
return (
<Container maxWidth="xl">
{!props.connected && !props.loading && (
<Typography
variant="body2"
sx={{
color: 'red',
fontWeight: 'bold',
textAlign: 'center',
marginTop: '10px',
marginBottom: '10px'
}}
>
<FormattedMessage
id="installationOffline"
defaultMessage="Installation is currently offline. Showing last available data."
/>
</Typography>
)}
{isErrorDateModalOpen && (
<Modal open={isErrorDateModalOpen} onClose={() => {}}>
<Box
@ -509,6 +550,7 @@ function Overview(props: OverviewProps) {
</LocalizationProvider>
)}
<Grid container>
{!props.loading && (props.connected !== false || dailyDataArray.length > 0 || aggregatedDataArray.length > 0) && (<>
<Grid item xs={6} md={6}>
<Button
variant="contained"
@ -523,7 +565,7 @@ function Overview(props: OverviewProps) {
>
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
</Button>
{product !== 2 && (
{props.device !== 3 && (
<Button
variant="contained"
onClick={handleWeekData}
@ -540,7 +582,6 @@ function Overview(props: OverviewProps) {
</Button>
)}
{/*{aggregatedData && (*/}
<Button
variant="contained"
onClick={handleSetDate}
@ -555,7 +596,6 @@ function Overview(props: OverviewProps) {
>
<FormattedMessage id="set_date" defaultMessage="Set Date" />
</Button>
{/*)}*/}
</Grid>
<Grid
@ -602,6 +642,7 @@ function Overview(props: OverviewProps) {
<FormattedMessage id="goback" defaultMessage="Zoom in" />
</Button>
</Grid>
</>)}
{loading && (
<Container
@ -616,7 +657,7 @@ function Overview(props: OverviewProps) {
>
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
Fetching data...
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
</Typography>
</Container>
)}
@ -767,7 +808,7 @@ function Overview(props: OverviewProps) {
{
...aggregatedDataArray[aggregatedChartState]
.chartData.gridExportPower,
color: '#ff3333',
color: '#2e7d32',
type: 'bar'
},
{
@ -776,13 +817,13 @@ function Overview(props: OverviewProps) {
type: 'bar',
color: '#ff9900'
},
{
...(product !== 2 ? [{
name: 'Net Energy',
color: '#ff3333',
color: '#e65100',
type: 'line',
data: aggregatedDataArray[aggregatedChartState]
.netbalance
}
}] : [])
]}
height={400}
type={'bar'}
@ -799,6 +840,7 @@ function Overview(props: OverviewProps) {
alignItems="stretch"
spacing={3}
>
{!(aggregatedData && product === 2) && (
<Grid item md={6} xs={12}>
<Card
sx={{
@ -890,7 +932,8 @@ function Overview(props: OverviewProps) {
)}
</Card>
</Grid>
<Grid item md={6} xs={12}>
)}
<Grid item md={(aggregatedData && product === 2) ? 12 : 6} xs={12}>
<Card
sx={{
overflow: 'visible',
@ -958,11 +1001,14 @@ function Overview(props: OverviewProps) {
<ReactApexChart
options={{
...getChartOptions(
aggregatedDataArray[aggregatedChartState]
product === 2
? aggregatedDataArray[aggregatedChartState]
.chartOverview.dcPowerWithoutHeating
: aggregatedDataArray[aggregatedChartState]
.chartOverview.dcPower,
'weekly',
aggregatedDataArray[aggregatedChartState].datelist,
false
product === 2
)
}}
series={[
@ -971,11 +1017,11 @@ function Overview(props: OverviewProps) {
.chartData.dcChargingPower,
color: '#008FFB'
},
{
...(product !== 2 ? [{
...aggregatedDataArray[aggregatedChartState]
.chartData.heatingPower,
color: '#ff9900'
},
}] : []),
{
...aggregatedDataArray[aggregatedChartState]
.chartData.dcDischargingPower,
@ -1027,7 +1073,8 @@ function Overview(props: OverviewProps) {
alignItems="stretch"
spacing={3}
>
<Grid item md={product === 2 ? 12 : 6} xs={12}>
{product !== 2 && (
<Grid item md={6} xs={12}>
<Card
sx={{
overflow: 'visible',
@ -1088,6 +1135,7 @@ function Overview(props: OverviewProps) {
/>
</Card>
</Grid>
)}
{product !== 2 && (
<Grid item md={6} xs={12}>
<Card
@ -1344,6 +1392,63 @@ function Overview(props: OverviewProps) {
</Grid>
</Grid>
{aggregatedData && product === 2 && (
<Grid
container
direction="row"
justifyContent="center"
alignItems="stretch"
spacing={3}
>
<Grid item md={12} xs={12}>
<Card
sx={{
overflow: 'visible',
marginTop: '30px',
marginBottom: '30px'
}}
>
<Box
sx={{
marginLeft: '20px'
}}
>
<Box display="flex" alignItems="center">
<Box>
<Typography variant="subtitle1" noWrap>
<FormattedMessage
id="ac_load_aggregated"
defaultMessage="AC Load Energy"
/>
</Typography>
</Box>
</Box>
</Box>
<ReactApexChart
options={{
...getChartOptions(
aggregatedDataArray[aggregatedChartState]
.chartOverview.ACLoad,
'weekly',
aggregatedDataArray[aggregatedChartState].datelist,
true
)
}}
series={[
{
...aggregatedDataArray[aggregatedChartState]
.chartData.acLoad,
color: '#ff9900'
}
]}
type="bar"
height={400}
/>
</Card>
</Grid>
</Grid>
)}
{dailyData && (
<Grid
container
@ -1485,6 +1590,10 @@ function Overview(props: OverviewProps) {
);
};
if (props.loading) {
return null;
}
return <>{renderGraphs()}</>;
}

View File

@ -28,6 +28,8 @@ import { UserType } from '../../../interfaces/UserTypes';
interface salidomoOverviewProps {
s3Credentials: I_S3Credentials;
id: number;
connected?: boolean;
loading?: boolean;
}
const computeLast7Days = (): string[] => {
@ -405,7 +407,7 @@ const computeLast7Days = (): string[] => {
// >
// <CircularProgress size={60} style={{ color: '#ffc04d' }} />
// <Typography variant="body2" style={{ color: 'black' }} mt={2}>
// Fetching data...
// <FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
// </Typography>
// </Container>
// )}
@ -750,6 +752,23 @@ function SalidomoOverview(props: salidomoOverviewProps) {
const renderGraphs = () => {
return (
<Container maxWidth="xl">
{!props.connected && !props.loading && (
<Typography
variant="body2"
sx={{
color: 'red',
fontWeight: 'bold',
textAlign: 'center',
marginTop: '10px',
marginBottom: '10px'
}}
>
<FormattedMessage
id="installationOffline"
defaultMessage="Installation is currently offline. Showing last available data."
/>
</Typography>
)}
{isErrorDateModalOpen && (
<Modal open={isErrorDateModalOpen} onClose={() => {}}>
<Box
@ -874,6 +893,7 @@ function SalidomoOverview(props: salidomoOverviewProps) {
</LocalizationProvider>
)}
<Grid container>
{!props.loading && (props.connected !== false || aggregatedDataArray.length > 0) && (<>
<Grid item xs={6} md={6}>
<Button
variant="contained"
@ -931,6 +951,7 @@ function SalidomoOverview(props: salidomoOverviewProps) {
<FormattedMessage id="goback" defaultMessage="Zoom in" />
</Button>
</Grid>
</>)}
{loading && (
<Container
@ -945,7 +966,7 @@ function SalidomoOverview(props: salidomoOverviewProps) {
>
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
Fetching data...
<FormattedMessage id="fetchingData" defaultMessage="Fetching data..." />
</Typography>
</Container>
)}
@ -1123,6 +1144,10 @@ function SalidomoOverview(props: salidomoOverviewProps) {
);
};
if (props.loading) {
return null;
}
return <>{renderGraphs()}</>;
}

View File

@ -14,6 +14,7 @@ import { JSONRecordData } from '../Log/graph.util';
import { useLocation, useNavigate } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
import CircularProgress from '@mui/material/CircularProgress';
import { FormattedMessage } from 'react-intl';
interface PvViewProps {
values: JSONRecordData;
@ -80,10 +81,10 @@ function PvView(props: PvViewProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Unable to communicate with the installation
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
</Typography>
</Container>
)}
@ -105,10 +106,10 @@ function PvView(props: PvViewProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Pv view is not available at the moment
<FormattedMessage id="pvViewNotAvailable" defaultMessage="Pv view is not available at the moment" />
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
</Typography>
</Container>
)}
@ -130,10 +131,10 @@ function PvView(props: PvViewProps) {
<Table sx={{ minWidth: 250 }} aria-label={`CU${deviceId} table`}>
<TableHead>
<TableRow>
<TableCell align="center">Pv</TableCell>
<TableCell align="center">Power</TableCell>
<TableCell align="center">Voltage</TableCell>
<TableCell align="center">Current</TableCell>
<TableCell align="center"><FormattedMessage id="pv" defaultMessage="Pv" /></TableCell>
<TableCell align="center"><FormattedMessage id="power" defaultMessage="Power" /></TableCell>
<TableCell align="center"><FormattedMessage id="voltage" defaultMessage="Voltage" /></TableCell>
<TableCell align="center"><FormattedMessage id="current" defaultMessage="Current" /></TableCell>
</TableRow>
</TableHead>
<TableBody>

View File

@ -20,7 +20,7 @@ import {
useTheme
} from '@mui/material';
import { I_Installation } from 'src/interfaces/InstallationTypes';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { useLocation, useNavigate } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
import CancelIcon from '@mui/icons-material/Cancel';
@ -32,6 +32,7 @@ interface FlatInstallationViewProps {
}
const FlatInstallationView = (props: FlatInstallationViewProps) => {
const intl = useIntl();
const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
const currentLocation = useLocation();
@ -182,7 +183,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
>
<FormControl sx={{ flex: 1 }}>
<TextField
placeholder="Search"
placeholder={intl.formatMessage({ id: 'search' })}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
fullWidth
@ -206,7 +207,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<Select
value={sortByStatus}
onChange={(e) => setSortByStatus(e.target.value)}
label="Show Only"
label={intl.formatMessage({ id: 'showOnly' })}
>
{[
'All Installations',
@ -232,7 +233,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<Select
value={sortByAction}
onChange={(e) => setSortByAction(e.target.value)}
label="Show Only"
label={intl.formatMessage({ id: 'showOnly' })}
>
{[
'All Installations',

View File

@ -157,8 +157,31 @@ function SalidomoInstallation(props: singleInstallationProps) {
setCurrentTab(path[path.length - 1]);
}, [location]);
const fetchDataForOneTime = async () => {
var timeperiodToSearch = 30;
for (var i = 0; i < timeperiodToSearch; i += 1) {
var timestampToFetch = UnixTime.now().earlier(TimeSpan.fromMinutes(i));
try {
var res = await fetchDataJson(timestampToFetch, s3Credentials, true);
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
setConnected(true);
setLoading(false);
return true;
}
} catch (err) {
console.error('Error fetching data:', err);
setConnected(false);
setLoading(false);
return false;
}
}
setConnected(false);
setLoading(false);
return false;
};
useEffect(() => {
if (location.includes('batteryview')) {
if (location.includes('batteryview') || currentTab == 'overview') {
if (location.includes('batteryview') && !location.includes('mainstats')) {
if (!continueFetching.current) {
continueFetching.current = true;
@ -168,6 +191,10 @@ function SalidomoInstallation(props: singleInstallationProps) {
}
}
}
// Fetch one time in overview tab to determine connectivity
if (currentTab == 'overview') {
fetchDataForOneTime();
}
return () => {
continueFetching.current = false;
@ -295,7 +322,6 @@ function SalidomoInstallation(props: singleInstallationProps) {
</div>
{loading &&
currentTab != 'information' &&
currentTab != 'overview' &&
currentTab != 'manage' &&
currentTab != 'history' &&
currentTab != 'log' && (
@ -315,7 +341,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Connecting to the device...
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
</Typography>
</Container>
)}
@ -357,6 +383,8 @@ function SalidomoInstallation(props: singleInstallationProps) {
<SalidomoOverview
s3Credentials={s3Credentials}
id={props.current_installation.id}
connected={connected}
loading={loading}
></SalidomoOverview>
}
/>

View File

@ -288,6 +288,7 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
? routes[tab.value]
: navigateToTabPath(location.pathname, routes[tab.value])
}
id={`tour-tab-${tab.value}`}
/>
))}
</Tabs>
@ -349,6 +350,7 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
component={Link}
label={tab.label}
to={routes[tab.value]}
id={`tour-tab-${tab.value}`}
/>
))}
</Tabs>

View File

@ -0,0 +1,614 @@
import { useEffect, useState, useMemo } from 'react';
import { useIntl, FormattedMessage } from 'react-intl';
import {
Alert,
Box,
CircularProgress,
Container,
Grid,
Paper,
TextField,
Typography
} from '@mui/material';
import { Line } from 'react-chartjs-2';
import {
Chart,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend
} from 'chart.js';
import axiosConfig from 'src/Resources/axiosConfig';
import { SavingsCards } from './WeeklyReport';
Chart.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend
);
// ── Interfaces ───────────────────────────────────────────────
interface DailyEnergyData {
date: string;
pvProduction: number;
loadConsumption: number;
gridImport: number;
gridExport: number;
batteryCharged: number;
batteryDischarged: number;
}
interface HourlyEnergyRecord {
date: string;
hour: number;
pvKwh: number;
loadKwh: number;
gridImportKwh: number;
batteryChargedKwh: number;
batteryDischargedKwh: number;
battSoC: number;
}
// ── Date Helpers ─────────────────────────────────────────────
/**
* Anchor date for the 7-day strip. Returns last completed Sunday.
* To switch to live-data mode later, change to: () => new Date()
*/
function getDataAnchorDate(): Date {
const today = new Date();
const dow = today.getDay(); // 0=Sun
const lastSunday = new Date(today);
lastSunday.setDate(today.getDate() - (dow === 0 ? 7 : dow));
lastSunday.setHours(0, 0, 0, 0);
return lastSunday;
}
function getWeekRange(anchor: Date): { monday: Date; sunday: Date } {
const sunday = new Date(anchor);
const monday = new Date(sunday);
monday.setDate(sunday.getDate() - 6);
return { monday, sunday };
}
function formatDateISO(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function getWeekDays(monday: Date): Date[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
return d;
});
}
// ── Main Component ───────────────────────────────────────────
export default function DailySection({
installationId
}: {
installationId: number;
}) {
const intl = useIntl();
const anchor = useMemo(() => getDataAnchorDate(), []);
const { monday, sunday } = useMemo(() => getWeekRange(anchor), [anchor]);
const weekDays = useMemo(() => getWeekDays(monday), [monday]);
const [weekRecords, setWeekRecords] = useState<DailyEnergyData[]>([]);
const [weekHourlyRecords, setWeekHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
const [selectedDate, setSelectedDate] = useState(formatDateISO(sunday));
const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null);
const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
const [loadingWeek, setLoadingWeek] = useState(false);
const [noData, setNoData] = useState(false);
// Fetch week data (daily + hourly) via combined endpoint with xlsx fallback + cache
useEffect(() => {
setLoadingWeek(true);
axiosConfig
.get('/GetDailyDetailRecords', {
params: {
installationId,
from: formatDateISO(monday),
to: formatDateISO(sunday)
}
})
.then((res) => {
const daily = res.data?.dailyRecords?.records ?? [];
const hourly = res.data?.hourlyRecords?.records ?? [];
setWeekRecords(Array.isArray(daily) ? daily : []);
setWeekHourlyRecords(Array.isArray(hourly) ? hourly : []);
})
.catch(() => {
setWeekRecords([]);
setWeekHourlyRecords([]);
})
.finally(() => setLoadingWeek(false));
}, [installationId, monday, sunday]);
// When selected date changes, extract data from week cache or fetch
useEffect(() => {
setNoData(false);
setSelectedDayRecord(null);
// Try week cache first
const cachedDay = weekRecords.find((r) => r.date === selectedDate);
const cachedHours = weekHourlyRecords.filter((r) => r.date === selectedDate);
if (cachedDay) {
setSelectedDayRecord(cachedDay);
setHourlyRecords(cachedHours);
return;
}
// Not in cache (date picker outside strip) — fetch via combined endpoint
axiosConfig
.get('/GetDailyDetailRecords', {
params: { installationId, from: selectedDate, to: selectedDate }
})
.then((res) => {
const daily = res.data?.dailyRecords?.records ?? [];
const hourly = res.data?.hourlyRecords?.records ?? [];
setHourlyRecords(Array.isArray(hourly) ? hourly : []);
if (Array.isArray(daily) && daily.length > 0) {
setSelectedDayRecord(daily[0]);
} else {
setNoData(true);
}
})
.catch(() => {
setHourlyRecords([]);
setNoData(true);
});
}, [installationId, selectedDate, weekRecords, weekHourlyRecords]);
const record = selectedDayRecord;
const kpis = useMemo(() => computeKPIs(record), [record]);
const handleDatePicker = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDate(e.target.value);
};
const handleStripSelect = (date: string) => {
setSelectedDate(date);
setNoData(false);
};
const dt = new Date(selectedDate + 'T00:00:00');
const dateLabel = dt.toLocaleDateString(intl.locale, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
return (
<>
{/* Date Picker */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Typography variant="body1" fontWeight="bold">
<FormattedMessage id="selectDate" defaultMessage="Select Date" />
</Typography>
<TextField
type="date"
size="small"
value={selectedDate}
onChange={handleDatePicker}
inputProps={{ max: formatDateISO(new Date()) }}
sx={{ width: 200 }}
/>
</Box>
{/* 7-Day Strip */}
<DayStrip
weekDays={weekDays}
weekRecords={weekRecords}
selectedDate={selectedDate}
onSelect={handleStripSelect}
sunday={sunday}
loading={loadingWeek}
/>
{/* Loading state */}
{loadingWeek && !record && (
<Container
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '20vh'
}}
>
<CircularProgress size={40} style={{ color: '#ffc04d' }} />
</Container>
)}
{/* No data state */}
{!loadingWeek && noData && !record && (
<Alert severity="info" sx={{ mb: 3 }}>
<FormattedMessage
id="noDataForDate"
defaultMessage="No data available for the selected date."
/>
</Alert>
)}
{/* Day detail */}
{record && (
<>
{/* Header */}
<Paper
sx={{ bgcolor: '#2c3e50', color: '#fff', p: 3, mb: 3, borderRadius: 2 }}
>
<Typography variant="h5" fontWeight="bold">
<FormattedMessage
id="dailyReportTitle"
defaultMessage="Daily Energy Summary"
/>
</Typography>
<Typography variant="body2" sx={{ opacity: 0.8, mt: 0.5 }}>
{dateLabel}
</Typography>
</Paper>
{/* KPI Cards */}
<Paper sx={{ p: 3, mb: 3 }}>
<SavingsCards
intl={intl}
energySaved={+kpis.energySaved.toFixed(1)}
savingsCHF={kpis.savingsCHF}
selfSufficiency={kpis.selfSufficiency}
batteryEfficiency={kpis.batteryEfficiency}
/>
</Paper>
{/* Intraday Chart */}
<IntradayChart
hourlyData={hourlyRecords}
loading={loadingWeek}
/>
{/* Summary Table */}
<DailySummaryTable record={record} />
</>
)}
</>
);
}
// ── KPI Computation ──────────────────────────────────────────
function computeKPIs(record: DailyEnergyData | null) {
if (!record) {
return { energySaved: 0, savingsCHF: 0, selfSufficiency: 0, batteryEfficiency: 0 };
}
const energySaved = Math.max(0, record.loadConsumption - record.gridImport);
const savingsCHF = +(energySaved * 0.39).toFixed(2);
const selfSufficiency =
record.loadConsumption > 0
? Math.min(100, (1 - record.gridImport / record.loadConsumption) * 100)
: 0;
const batteryEfficiency =
record.batteryCharged > 0
? Math.min(100, Math.floor((record.batteryDischarged / record.batteryCharged) * 100))
: 0;
return { energySaved, savingsCHF, selfSufficiency, batteryEfficiency };
}
// ── DayStrip ─────────────────────────────────────────────────
function DayStrip({
weekDays,
weekRecords,
selectedDate,
onSelect,
sunday,
loading
}: {
weekDays: Date[];
weekRecords: DailyEnergyData[];
selectedDate: string;
onSelect: (date: string) => void;
sunday: Date;
loading: boolean;
}) {
const intl = useIntl();
const sundayLabel = sunday.toLocaleDateString(intl.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
return (
<Box sx={{ mb: 3 }}>
<Box
sx={{
display: 'flex',
gap: 1,
overflowX: 'auto',
pb: 1,
mb: 1
}}
>
{weekDays.map((day) => {
const dateStr = formatDateISO(day);
const isSelected = dateStr === selectedDate;
const record = weekRecords.find((r) => r.date === dateStr);
const selfSuff =
record && record.loadConsumption > 0
? Math.min(100, (1 - record.gridImport / record.loadConsumption) * 100)
: null;
return (
<Paper
key={dateStr}
onClick={() => onSelect(dateStr)}
elevation={isSelected ? 4 : 1}
sx={{
flex: '1 1 0',
minWidth: 80,
p: 1.5,
textAlign: 'center',
cursor: 'pointer',
border: isSelected ? '2px solid #2980b9' : '2px solid transparent',
bgcolor: isSelected ? '#e3f2fd' : '#fff',
transition: 'all 0.15s',
'&:hover': { bgcolor: isSelected ? '#e3f2fd' : '#f5f5f5' }
}}
>
<Typography variant="caption" fontWeight="bold" sx={{ color: '#666' }}>
{day.toLocaleDateString(intl.locale, { weekday: 'short' })}
</Typography>
<Typography variant="h6" fontWeight="bold" sx={{ lineHeight: 1.2 }}>
{day.getDate()}
</Typography>
<Typography
variant="caption"
sx={{ color: selfSuff != null ? '#8e44ad' : '#ccc' }}
>
{loading
? '...'
: selfSuff != null
? `${selfSuff.toFixed(0)}%`
: '—'}
</Typography>
</Paper>
);
})}
</Box>
<Typography variant="caption" sx={{ color: '#888' }}>
<FormattedMessage
id="dataUpTo"
defaultMessage="Data up to {date}"
values={{ date: sundayLabel }}
/>
</Typography>
</Box>
);
}
// ── IntradayChart ────────────────────────────────────────────
const HOUR_LABELS = Array.from({ length: 24 }, (_, i) =>
`${String(i).padStart(2, '0')}:00`
);
function IntradayChart({
hourlyData,
loading
}: {
hourlyData: HourlyEnergyRecord[];
loading: boolean;
}) {
const intl = useIntl();
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4, mb: 3 }}>
<CircularProgress size={30} style={{ color: '#ffc04d' }} />
</Box>
);
}
if (hourlyData.length === 0) {
return (
<Alert severity="info" sx={{ mb: 3 }}>
<FormattedMessage
id="noHourlyData"
defaultMessage="Hourly data not available for this day."
/>
</Alert>
);
}
const hourMap = new Map(hourlyData.map((h) => [h.hour, h]));
const pvData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.pvKwh ?? null);
const loadData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.loadKwh ?? null);
const batteryData = HOUR_LABELS.map((_, i) => {
const h = hourMap.get(i);
return h ? h.batteryDischargedKwh - h.batteryChargedKwh : null;
});
const socData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.battSoC ?? null);
const chartData = {
labels: HOUR_LABELS,
datasets: [
{
label: intl.formatMessage({ id: 'pvProduction', defaultMessage: 'PV Production' }),
data: pvData,
borderColor: '#f1c40f',
backgroundColor: 'rgba(241,196,15,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
yAxisID: 'y'
},
{
label: intl.formatMessage({ id: 'consumption', defaultMessage: 'Consumption' }),
data: loadData,
borderColor: '#e74c3c',
backgroundColor: 'rgba(231,76,60,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
yAxisID: 'y'
},
{
label: intl.formatMessage({ id: 'batteryPower', defaultMessage: 'Battery Power' }),
data: batteryData,
borderColor: '#3498db',
backgroundColor: 'rgba(52,152,219,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
yAxisID: 'y'
},
{
label: intl.formatMessage({ id: 'batterySoCLabel', defaultMessage: 'Battery SoC' }),
data: socData,
borderColor: '#27ae60',
borderDash: [6, 3],
backgroundColor: 'transparent',
tension: 0.3,
pointRadius: 2,
yAxisID: 'soc'
}
]
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: { position: 'top' as const }
},
scales: {
y: {
position: 'left' as const,
title: {
display: true,
text: intl.formatMessage({ id: 'powerKw', defaultMessage: 'Power (kW)' })
}
},
soc: {
position: 'right' as const,
min: 0,
max: 100,
title: {
display: true,
text: intl.formatMessage({ id: 'socPercent', defaultMessage: 'SoC (%)' })
},
grid: { drawOnChartArea: false }
}
}
};
return (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage
id="intradayChart"
defaultMessage="Intraday Power Flow"
/>
</Typography>
<Box sx={{ height: 350 }}>
<Line data={chartData} options={chartOptions} />
</Box>
</Paper>
);
}
// ── DailySummaryTable ────────────────────────────────────────
function DailySummaryTable({ record }: { record: DailyEnergyData }) {
return (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="dailySummary" defaultMessage="Daily Summary" />
</Typography>
<Box
component="table"
sx={{
width: '100%',
borderCollapse: 'collapse',
'& td, & th': { p: 1.5, 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="total" defaultMessage="Total" />
</th>
</tr>
</thead>
<tbody>
<tr>
<td><FormattedMessage id="pvProduction" defaultMessage="PV Production" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
{record.pvProduction.toFixed(1)} kWh
</td>
</tr>
<tr>
<td><FormattedMessage id="consumption" defaultMessage="Consumption" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
{record.loadConsumption.toFixed(1)} kWh
</td>
</tr>
<tr>
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
{record.gridImport.toFixed(1)} kWh
</td>
</tr>
<tr>
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>
{record.gridExport.toFixed(1)} kWh
</td>
</tr>
<tr style={{ background: '#f0f7ff' }}>
<td>
<strong>
<FormattedMessage id="batteryCharged" defaultMessage="Battery Charged" />
</strong>
</td>
<td style={{ textAlign: 'right', fontWeight: 'bold', color: '#2980b9' }}>
{record.batteryCharged.toFixed(1)} kWh
</td>
</tr>
<tr style={{ background: '#f0f7ff' }}>
<td>
<strong>
<FormattedMessage
id="batteryDischarged"
defaultMessage="Battery Discharged"
/>
</strong>
</td>
<td style={{ textAlign: 'right', fontWeight: 'bold', color: '#e67e22' }}>
{record.batteryDischarged.toFixed(1)} kWh
</td>
</tr>
</tbody>
</Box>
</Paper>
);
}

View File

@ -111,20 +111,52 @@ function SodioHomeInstallation(props: singleInstallationProps) {
return btoa(String.fromCharCode(...combined));
}
const fetchDataPeriodically = async () => {
var timeperiodToSearch = 350;
let res;
let timestampToFetch;
// Probe multiple timestamps in parallel, return first successful result
const probeTimestampBatch = async (
offsets: number[]
): Promise<{ res: any; timestamp: UnixTime } | null> => {
const now = UnixTime.now();
const promises = offsets.map(async (offset) => {
const ts = now.earlier(TimeSpan.fromSeconds(offset));
const result = await fetchDataJson(ts, s3Credentials, false);
if (result !== FetchResult.notAvailable && result !== FetchResult.tryLater) {
return { res: result, timestamp: ts };
}
return null;
});
for (var i = 0; i < timeperiodToSearch; i += 30) {
const results = await Promise.all(promises);
// Return the most recent hit (smallest offset = first in array)
return results.find((r) => r !== null) || null;
};
const fetchDataPeriodically = async () => {
let res;
let timestampToFetch: UnixTime;
// Search backward in parallel batches of 10 timestamps (2s apart)
// Each batch covers 20 seconds, so 20 batches cover 400 seconds
const batchSize = 10;
const step = 2; // 2-second steps to match even-rounding granularity
const maxAge = 400;
let found = false;
for (let batchStart = 0; batchStart < maxAge; batchStart += batchSize * step) {
if (!continueFetching.current) {
return false;
}
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
const offsets = [];
for (let j = 0; j < batchSize; j++) {
const offset = batchStart + j * step;
if (offset < maxAge) offsets.push(offset);
}
try {
res = await fetchDataJson(timestampToFetch, s3Credentials, false);
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
const hit = await probeTimestampBatch(offsets);
if (hit) {
res = hit.res;
timestampToFetch = hit.timestamp;
found = true;
break;
}
} catch (err) {
@ -133,7 +165,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
}
}
if (i >= timeperiodToSearch) {
if (!found) {
setConnected(false);
setLoading(false);
return false;
@ -154,10 +186,12 @@ function SodioHomeInstallation(props: singleInstallationProps) {
await timeout(2000);
}
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(60));
// Advance by 150s to find the next chunk (15 records × 10s interval)
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(150));
console.log('NEW TIMESTAMP TO FETCH IS ' + timestampToFetch);
for (i = 0; i < 30; i++) {
let foundNext = false;
for (var i = 0; i < 60; i++) {
if (!continueFetching.current) {
return false;
}
@ -169,6 +203,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
res !== FetchResult.notAvailable &&
res !== FetchResult.tryLater
) {
foundNext = true;
break;
}
} catch (err) {
@ -177,24 +212,30 @@ function SodioHomeInstallation(props: singleInstallationProps) {
}
timestampToFetch = timestampToFetch.later(TimeSpan.fromSeconds(1));
}
if (i == 30) {
if (!foundNext) {
return false;
}
}
};
const fetchDataForOneTime = async () => {
var timeperiodToSearch = 300; // 5 minutes to cover ~2 upload cycles (150s each)
// Search backward in parallel batches of 10 timestamps (2s apart)
const batchSize = 10;
const step = 2;
const maxAge = 400;
let res;
let timestampToFetch;
// Search from NOW backward to find the most recent data
// Step by 50 seconds - data is uploaded every ~150s, so finer steps are wasteful
for (var i = 0; i < timeperiodToSearch; i += 50) {
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
for (let batchStart = 0; batchStart < maxAge; batchStart += batchSize * step) {
const offsets = [];
for (let j = 0; j < batchSize; j++) {
const offset = batchStart + j * step;
if (offset < maxAge) offsets.push(offset);
}
try {
res = await fetchDataJson(timestampToFetch, s3Credentials, false);
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
const hit = await probeTimestampBatch(offsets);
if (hit) {
res = hit.res;
break;
}
} catch (err) {
@ -203,11 +244,12 @@ function SodioHomeInstallation(props: singleInstallationProps) {
}
}
if (i >= timeperiodToSearch) {
if (!res) {
setConnected(false);
setLoading(false);
return false;
}
setConnected(true);
setLoading(false);
@ -215,12 +257,6 @@ function SodioHomeInstallation(props: singleInstallationProps) {
const timestamps = Object.keys(res).sort((a, b) => Number(b) - Number(a));
const latestTimestamp = timestamps[0];
setValues(res[latestTimestamp]);
// setValues(
// extractValues({
// time: UnixTime.fromTicks(parseInt(timestamp, 10)),
// value: res[timestamp]
// })
// );
return true;
};
@ -240,6 +276,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
currentTab == 'live' ||
currentTab == 'pvview' ||
currentTab == 'configuration' ||
currentTab == 'overview' ||
location.includes('batteryview')
) {
//Fetch periodically if the tab is live, pvview or batteryview
@ -257,14 +294,20 @@ function SodioHomeInstallation(props: singleInstallationProps) {
}
}
}
// Fetch periodically in configuration tab (every 30 seconds to detect S3 updates)
// Fetch one time in overview tab to determine connectivity
if (currentTab == 'overview') {
fetchDataForOneTime();
return () => {
continueFetching.current = false;
};
}
// Fetch periodically in configuration tab to detect S3 config updates
if (currentTab == 'configuration') {
fetchDataForOneTime(); // Initial fetch
fetchDataForOneTime();
const configRefreshInterval = setInterval(() => {
console.log('Refreshing configuration data from S3...');
fetchDataForOneTime();
}, 60000); // Refresh every 60 seconds (data uploads every ~150s)
}, 30000);
return () => {
continueFetching.current = false;
@ -340,11 +383,10 @@ function SodioHomeInstallation(props: singleInstallationProps) {
fontSize: '14px'
}}
>
{props.current_installation.device === 4
? values.InverterRecord?.WorkingMode
: props.current_installation.device === 3
? values.InverterRecord?.OperatingMode
: values.Config.OperatingPriority}
{values.InverterRecord?.OperatingPriority
?? values.InverterRecord?.WorkingMode
?? values.InverterRecord?.OperatingMode
?? values.Config?.OperatingPriority}
</Typography>
</div>
)}
@ -428,7 +470,6 @@ function SodioHomeInstallation(props: singleInstallationProps) {
</div>
{loading &&
currentTab != 'information' &&
currentTab != 'overview' &&
currentTab != 'manage' &&
currentTab != 'history' &&
currentTab != 'log' &&
@ -449,7 +490,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Connecting to the device...
<FormattedMessage id="connectingToDevice" defaultMessage="Connecting to the device..." />
</Typography>
</Container>
)}
@ -559,6 +600,9 @@ function SodioHomeInstallation(props: singleInstallationProps) {
<Overview
s3Credentials={s3Credentials}
id={props.current_installation.id}
device={props.current_installation.device}
connected={connected}
loading={loading}
/>
}
/>

View File

@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react';
import { FormControl, Grid, InputAdornment, TextField } from '@mui/material';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
import { useIntl } from 'react-intl';
import { I_Installation } from '../../../interfaces/InstallationTypes';
import { Route, Routes, useLocation } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
@ -12,6 +13,7 @@ interface installationSearchProps {
}
function InstallationSearch(props: installationSearchProps) {
const intl = useIntl();
const [searchTerm, setSearchTerm] = useState('');
const currentLocation = useLocation();
// const [filteredData, setFilteredData] = useState(props.installations);
@ -60,7 +62,7 @@ function InstallationSearch(props: installationSearchProps) {
>
<FormControl variant="outlined">
<TextField
placeholder="Search"
placeholder={intl.formatMessage({ id: 'search' })}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
fullWidth

View File

@ -17,7 +17,7 @@ import {
} from '@mui/material';
import React, { useContext, useState, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import MenuItem from '@mui/material/MenuItem';
@ -39,6 +39,7 @@ interface SodistoreHomeConfigurationProps {
}
function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
const intl = useIntl();
if (props.values === null) {
return null;
}
@ -46,20 +47,17 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
const device = props.installation.device;
const OperatingPriorityOptions =
device === 3 // Growatt
device === 3 || device === 4
? ['LoadPriority', 'BatteryPriority', 'GridPriority']
: device === 4 // Sinexcel
? [
'SpontaneousSelfUse',
'TimeChargeDischarge',
// 'TimeOfUsePowerPrice',
// 'DisasterStandby',
// 'ManualControl',
'PvPriorityCharging',
// 'PrioritySellElectricity'
]
: [];
// Sinexcel S3 stores WorkingMode enum names — map them to Growatt-style display names
const sinexcelS3ToDisplayName: Record<string, string> = {
'SpontaneousSelfUse': 'LoadPriority',
'TimeChargeDischarge': 'BatteryPriority',
'PvPriorityCharging': 'GridPriority',
};
const [errors, setErrors] = useState({
minimumSoC: false,
gridSetPoint: false
@ -81,39 +79,21 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
const { currentUser, setUser } = context;
const { product, setProduct } = useContext(ProductIdContext);
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>({
minimumSoC: props.values.Config.MinSoc,
maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
operatingPriority: OperatingPriorityOptions.indexOf(
props.values.Config.OperatingPriority
),
batteriesCount: props.values.Config.BatteriesCount,
clusterNumber: props.values.Config.ClusterNumber??1,
PvNumber: props.values.Config.PvNumber??0,
timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0, // default 0 W
startTimeChargeandDischargeDayandTime:
props.values.Config?.StartTimeChargeandDischargeDayandTime
? dayjs(props.values.Config.StartTimeChargeandDischargeDayandTime).toDate()
: null,
stopTimeChargeandDischargeDayandTime:
props.values.Config?.StopTimeChargeandDischargeDayandTime
? dayjs(props.values.Config.StopTimeChargeandDischargeDayandTime).toDate()
: null,
// controlPermission: props.values.Config.ControlPermission??false,
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
});
// Resolve S3 OperatingPriority to display index (Sinexcel uses different enum names)
const resolveOperatingPriorityIndex = (s3Value: string) => {
const displayName = device === 4 ? (sinexcelS3ToDisplayName[s3Value] ?? s3Value) : s3Value;
return OperatingPriorityOptions.indexOf(displayName);
};
// Storage key for pending config (optimistic update)
const pendingConfigKey = `pendingConfig_${props.id}`;
// Helper to get current S3 values
const getS3Values = () => ({
// Helper to build form values from S3 data
const getS3Values = (): Partial<ConfigurationValues> => ({
minimumSoC: props.values.Config.MinSoc,
maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
operatingPriority: OperatingPriorityOptions.indexOf(
operatingPriority: resolveOperatingPriorityIndex(
props.values.Config.OperatingPriority
),
batteriesCount: props.values.Config.BatteriesCount,
@ -131,49 +111,83 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
});
// Sync form values when props.values changes
// Logic: Use localStorage only briefly after submit to prevent flicker, then trust S3
// Restore pending config from localStorage, converting date strings back to Date objects.
// Returns { values, s3ConfigSnapshot } or null if no pending config.
const restorePendingConfig = () => {
try {
const pendingStr = localStorage.getItem(pendingConfigKey);
if (!pendingStr) return null;
const pending = JSON.parse(pendingStr);
const v = pending.values;
const values: Partial<ConfigurationValues> = {
...v,
// JSON.stringify converts Date→string; restore them back to Date objects
startTimeChargeandDischargeDayandTime:
v.startTimeChargeandDischargeDayandTime
? dayjs(v.startTimeChargeandDischargeDayandTime).toDate()
: null,
stopTimeChargeandDischargeDayandTime:
v.stopTimeChargeandDischargeDayandTime
? dayjs(v.stopTimeChargeandDischargeDayandTime).toDate()
: null,
};
return { values, s3ConfigSnapshot: pending.s3ConfigSnapshot || null };
} catch (e) {
console.error('[Config:restore] Failed to parse localStorage', e);
localStorage.removeItem(pendingConfigKey);
return null;
}
};
// Fingerprint S3 Config for change detection (not value comparison)
const getS3ConfigFingerprint = () => JSON.stringify(props.values.Config);
// Initialize form from localStorage (if pending submit exists) or from S3
// This runs in the useState initializer so the component never renders stale values
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>(() => {
const pending = restorePendingConfig();
const s3 = getS3Values();
if (pending) {
// Check if S3 has new data since submit (fingerprint changed from snapshot)
const currentFingerprint = getS3ConfigFingerprint();
const s3Changed = pending.s3ConfigSnapshot && currentFingerprint !== pending.s3ConfigSnapshot;
if (s3Changed) {
// Device uploaded new data since our submit — trust S3 (device is authority)
localStorage.removeItem(pendingConfigKey);
return s3;
}
// S3 still has same data as when we submitted — show pending values
return pending.values;
}
return s3;
});
// When S3 data updates (polled every 60s), reconcile with any pending localStorage.
// Strategy: device is the authority. Once S3 Config changes from the snapshot taken at
// submit time, the device has uploaded new data — trust S3 regardless of values.
useEffect(() => {
const s3Values = getS3Values();
const pendingConfigStr = localStorage.getItem(pendingConfigKey);
const pending = restorePendingConfig();
if (pendingConfigStr) {
try {
const pendingConfig = JSON.parse(pendingConfigStr);
const submittedAt = pendingConfig.submittedAt || 0;
const timeSinceSubmit = Date.now() - submittedAt;
// Within 300 seconds of submit: use localStorage (waiting for S3 sync)
// This covers two full S3 upload cycles (150 sec × 2) to ensure new file is available
if (timeSinceSubmit < 300000) {
// Check if S3 now matches - if so, sync is complete
const s3MatchesPending =
s3Values.controlPermission === pendingConfig.values.controlPermission &&
s3Values.minimumSoC === pendingConfig.values.minimumSoC &&
s3Values.operatingPriority === pendingConfig.values.operatingPriority;
if (s3MatchesPending) {
// S3 synced! Clear localStorage and use S3 from now on
console.log('S3 synced with submitted config');
if (pending) {
const currentFingerprint = getS3ConfigFingerprint();
const s3Changed = pending.s3ConfigSnapshot && currentFingerprint !== pending.s3ConfigSnapshot;
if (s3Changed) {
// S3 Config changed from snapshot → device uploaded new data → trust S3
localStorage.removeItem(pendingConfigKey);
setFormValues(s3Values);
} else {
// Still waiting for sync, keep showing submitted values
console.log('Waiting for S3 sync, showing submitted values');
setFormValues(pendingConfig.values);
// S3 still has same data as at submit time — keep showing pending values
setFormValues(pending.values);
}
return;
}
// Timeout expired: clear localStorage, trust S3 completely
console.log('Timeout expired, trusting S3 data');
localStorage.removeItem(pendingConfigKey);
} catch (e) {
localStorage.removeItem(pendingConfigKey);
}
}
// No localStorage or expired: always use S3 (source of truth)
// No pending config — trust S3 (source of truth)
setFormValues(s3Values);
}, [props.values]);
@ -199,7 +213,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
const stopTimeInMinutes = stopHours * 60 + stopMinutes;
if (startTimeInMinutes >= stopTimeInMinutes) {
setDateSelectionError('Stop time must be later than start time');
setDateSelectionError(intl.formatMessage({ id: 'stopTimeMustBeLater' }));
setErrorDateModalOpen(true);
return false;
}
@ -246,12 +260,15 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
setUpdated(true);
setLoading(false);
// Save submitted values to localStorage for optimistic UI update
// This ensures the form shows correct values even before S3 syncs (up to 150 sec delay)
localStorage.setItem(pendingConfigKey, JSON.stringify({
// Save submitted values + S3 snapshot to localStorage for optimistic UI update.
// s3ConfigSnapshot = fingerprint of S3 Config at submit time.
// When S3 Config changes from this snapshot, the device has uploaded new data.
const cachePayload = {
values: formValues,
submittedAt: Date.now()
}));
submittedAt: Date.now(),
s3ConfigSnapshot: getS3ConfigFingerprint(),
};
localStorage.setItem(pendingConfigKey, JSON.stringify(cachePayload));
}
};
@ -459,7 +476,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
{/* fullWidth*/}
{/*/>*/}
<TextField
label="Minimum SoC (%)"
label={intl.formatMessage({ id: 'minimumSocPercent' })}
name="minimumSoC"
value={formValues.minimumSoC}
onChange={handleChange}
@ -529,21 +546,21 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
</div>
</>
{/* --- Sinexcel + TimeChargeDischarge --- */}
{/* --- Sinexcel + BatteryPriority (maps to TimeChargeDischarge on device) --- */}
{device === 4 &&
OperatingPriorityOptions[formValues.operatingPriority] ===
'TimeChargeDischarge' && (
'BatteryPriority' && (
<>
{/* Power input*/}
<div style={{ marginBottom: '5px' }}>
<TextField
label="Power (W)"
label={intl.formatMessage({ id: 'powerW' })}
name="timeChargeandDischargePower"
value={formValues.timeChargeandDischargePower}
onChange={(e) =>
handleTimeChargeDischargeChange(e.target.name, e.target.value)
}
helperText="Enter a positive or negative power value"
helperText={intl.formatMessage({ id: 'enterPowerValue' })}
fullWidth
/>
</div>
@ -553,7 +570,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker
ampm={false}
label="Start Date and Time (Start Time < Stop Time)"
label={intl.formatMessage({ id: 'startDateTime' })}
value={
formValues.startTimeChargeandDischargeDayandTime
? dayjs(formValues.startTimeChargeandDischargeDayandTime)
@ -585,7 +602,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker
ampm={false}
label="Stop Date and Time (Start Time < Stop Time)"
label={intl.formatMessage({ id: 'stopDateTime' })}
value={
formValues.stopTimeChargeandDischargeDayandTime
? dayjs(formValues.stopTimeChargeandDischargeDayandTime)
@ -651,7 +668,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
alignItems: 'center'
}}
>
Successfully applied configuration file
<FormattedMessage id="successfullyAppliedConfig" defaultMessage="Successfully applied configuration file" />
<IconButton
color="inherit"
size="small"
@ -671,7 +688,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
alignItems: 'center'
}}
>
An error has occurred
<FormattedMessage id="configErrorOccurred" defaultMessage="An error has occurred" />
<IconButton
color="inherit"
size="small"

View File

@ -19,8 +19,11 @@ import {
Typography
} from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import DownloadIcon from '@mui/icons-material/Download';
import RefreshIcon from '@mui/icons-material/Refresh';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import axiosConfig from 'src/Resources/axiosConfig';
import DailySection from './DailySection';
interface WeeklyReportProps {
installationId: number;
@ -273,6 +276,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
};
const tabs = [
{ label: intl.formatMessage({ id: 'dailyTab', defaultMessage: 'Daily' }), key: 'daily' },
{ label: intl.formatMessage({ id: 'weeklyTab', defaultMessage: 'Weekly' }), key: 'weekly' },
{ label: intl.formatMessage({ id: 'monthlyTab', defaultMessage: 'Monthly' }), key: 'monthly' },
{ label: intl.formatMessage({ id: 'yearlyTab', defaultMessage: 'Yearly' }), key: 'yearly' }
@ -281,15 +285,36 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
const safeTab = Math.min(activeTab, tabs.length - 1);
return (
<Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }}>
<Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }} className="report-container">
<style>{`
@media print {
body * { visibility: hidden; }
.report-container, .report-container * { visibility: visible; }
.report-container { position: absolute; left: 0; top: 0; width: 100%; padding: 20px; }
.no-print { display: none !important; }
}
`}</style>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }} className="no-print">
<Tabs
value={safeTab}
onChange={(_, v) => setActiveTab(v)}
sx={{ mb: 2, '& .MuiTab-root': { fontWeight: 'bold' } }}
sx={{ flex: 1, '& .MuiTab-root': { fontWeight: 'bold' } }}
>
{tabs.map(t => <Tab key={t.key} label={t.label} />)}
</Tabs>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => window.print()}
sx={{ ml: 2, whiteSpace: 'nowrap' }}
>
<FormattedMessage id="downloadPdf" defaultMessage="Download PDF" />
</Button>
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none' }}>
<DailySection installationId={installationId} />
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none' }}>
<WeeklySection
installationId={installationId}
@ -334,19 +359,19 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
fetchReport();
}, [installationId, intl.locale]);
const fetchReport = async () => {
const fetchReport = async (forceRegenerate = false) => {
setLoading(true);
setError(null);
try {
const res = await axiosConfig.get('/GetWeeklyReport', {
params: { installationId, language: intl.locale }
params: { installationId, language: intl.locale, forceRegenerate }
});
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/';
intl.formatMessage({ id: 'failedToLoadReport' });
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally {
setLoading(false);
@ -392,7 +417,7 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
const prev = report.previousWeek;
const currentWeekDayCount = Math.min(7, report.dailyData.length);
const previousWeekDayCount = Math.max(1, report.dailyData.length - currentWeekDayCount);
const previousWeekDayCount = 7;
const formatChange = (pct: number) =>
pct === 0 ? '—' : pct > 0 ? `+${pct.toFixed(1)}%` : `${pct.toFixed(1)}%`;
@ -434,6 +459,8 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
borderRadius: 2
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box>
<Typography variant="h5" fontWeight="bold">
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
</Typography>
@ -443,6 +470,17 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
<Typography variant="body2" sx={{ opacity: 0.7 }}>
{report.periodStart} {report.periodEnd}
</Typography>
</Box>
<Button
variant="outlined"
size="small"
startIcon={<RefreshIcon />}
onClick={() => fetchReport(true)}
sx={{ color: '#fff', borderColor: 'rgba(255,255,255,0.5)', '&:hover': { borderColor: '#fff' } }}
>
<FormattedMessage id="regenerateReport" defaultMessage="Regenerate" />
</Button>
</Box>
</Paper>
{/* Weekly Insights */}
@ -665,7 +703,7 @@ function WeeklyHistory({ installationId, latestMonthlyPeriodEnd, currentReportPe
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body1" fontWeight="bold" color="#8e44ad">{rec.selfSufficiencyPercent.toFixed(0)}%</Typography>
<Typography variant="caption" color="#888"><FormattedMessage id="solarCoverage" defaultMessage="Self-Sufficiency" /></Typography>
<Typography variant="caption" color="#888"><FormattedMessage id="solarCoverage" defaultMessage="Energy Independence" /></Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
@ -760,6 +798,7 @@ function MonthlySection({
countFn={(r: MonthlyReport) => r.weekCount}
sendEndpoint="/SendMonthlyReportEmail"
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
onRegenerate={(r: MonthlyReport) => onGenerate(r.year, r.month)}
/>
) : pendingMonths.length === 0 ? (
<Alert severity="info">
@ -833,6 +872,7 @@ function YearlySection({
countFn={(r: YearlyReport) => r.monthCount}
sendEndpoint="/SendYearlyReportEmail"
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
onRegenerate={(r: YearlyReport) => onGenerate(r.year)}
/>
) : pendingYears.length === 0 ? (
<Alert severity="info">
@ -852,7 +892,8 @@ function AggregatedSection<T extends ReportSummary>({
countLabelId,
countFn,
sendEndpoint,
sendParamsFn
sendParamsFn,
onRegenerate
}: {
reports: T[];
type: 'monthly' | 'yearly';
@ -861,9 +902,11 @@ function AggregatedSection<T extends ReportSummary>({
countFn: (r: T) => number;
sendEndpoint: string;
sendParamsFn: (r: T) => object;
onRegenerate?: (r: T) => void | Promise<void>;
}) {
const intl = useIntl();
const [selectedIdx, setSelectedIdx] = useState(0);
const [regenerating, setRegenerating] = useState(false);
if (reports.length === 0) {
return (
@ -909,7 +952,22 @@ function AggregatedSection<T extends ReportSummary>({
))}
</Select>
)}
<Box sx={{ ml: 'auto' }}>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
{onRegenerate && (
<Button
variant="outlined"
size="small"
disabled={regenerating}
startIcon={regenerating ? <CircularProgress size={14} /> : <RefreshIcon />}
onClick={async () => {
setRegenerating(true);
try { await onRegenerate(r); } finally { setRegenerating(false); }
}}
sx={{ textTransform: 'none' }}
>
<FormattedMessage id="regenerateReport" defaultMessage="Regenerate" />
</Button>
)}
<EmailBar onSend={handleSendEmail} />
</Box>
</Box>
@ -1016,7 +1074,7 @@ function InsightBox({ text, bullets }: { text: string; bullets: string[] }) {
);
}
function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: {
export function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: {
intl: any;
energySaved: number;
savingsCHF: number;

View File

@ -416,6 +416,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
icon={tab.icon}
component={Link}
label={tab.label}
id={`tour-tab-${tab.value}`}
to={
tab.value === 'list' || tab.value === 'tree'
? routes[tab.value]
@ -482,6 +483,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
component={Link}
label={tab.label}
to={routes[tab.value]}
id={`tour-tab-${tab.value}`}
/>
))}
</Tabs>

View File

@ -13,6 +13,7 @@ import {
JSONRecordData
} from '../Log/graph.util';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
import { FormattedMessage } from 'react-intl';
interface TopologyProps {
values: JSONRecordData;
@ -64,10 +65,10 @@ function Topology(props: TopologyProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Unable to communicate with the installation
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
</Typography>
</Container>
)}

View File

@ -13,6 +13,7 @@ import {
JSONRecordData
} from '../Log/graph.util';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
import { FormattedMessage } from 'react-intl';
interface TopologySodistoreHomeProps {
values: JSONRecordData;
@ -38,29 +39,31 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
const isMobile = window.innerWidth <= 1490;
const totalBatteryPower: number = Number(
props.values && props.values.InverterRecord
? Array.from({ length: props.batteryClusterNumber }).reduce(
(sum: number, _, index) => {
const i = index + 1;
const inv = props.values?.InverterRecord;
const hasDevices = !!inv?.Devices;
const rawPower =
props.values.InverterRecord[`Battery${i}Power`] as unknown;
const power = Number(rawPower) || 0;
return sum + power;
},
const totalBatteryPower: number = hasDevices
? (inv?.TotalBatteryPower ?? 0)
: Number(
Array.from({ length: props.batteryClusterNumber }).reduce(
(sum: number, _, index) => sum + (Number(inv?.[`Battery${index + 1}Power`]) || 0),
0
)
: 0
);
const pvPower =
props.values?.InverterRecord?.PvPower ??
const pvPower: number = hasDevices
? (inv?.TotalPhotovoltaicPower ?? 0)
: (inv?.PvPower ??
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
.map((key) => props.values?.InverterRecord?.[key] ?? 0)
.reduce((sum, val) => sum + val, 0);
.map((key) => inv?.[key] ?? 0)
.reduce((sum, val) => sum + val, 0));
const totalLoadPower: number = hasDevices
? (inv?.TotalLoadPower ?? 0)
: (inv?.ConsumptionPower ?? 0);
const totalGridPower: number =
inv?.TotalGridPower ?? inv?.GridPower ?? 0;
return (
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
@ -82,10 +85,10 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Unable to communicate with the installation
<FormattedMessage id="unableToCommunicate" defaultMessage="Unable to communicate with the installation" />
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
<FormattedMessage id="pleaseWaitOrRefresh" defaultMessage="Please wait or refresh the page" />
</Typography>
</Container>
)}
@ -141,7 +144,7 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
data: props.values?.InverterRecord
? [
{
value: props.values.InverterRecord.GridPower,
value: totalGridPower,
unit: 'W'
}
]
@ -153,14 +156,14 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
orientation: 'horizontal',
data: props.values?.InverterRecord
? {
value: props.values.InverterRecord.GridPower,
value: totalGridPower,
unit: 'W'
}
: undefined,
amount: props.values?.InverterRecord
? getAmount(
highestConnectionValue,
props.values.InverterRecord.GridPower
totalGridPower
)
: 0,
showValues: showValues
@ -224,7 +227,7 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
data: props.values?.InverterRecord
? [
{
value: props.values.InverterRecord.ConsumptionPower,
value: totalLoadPower,
unit: 'W'
}
]
@ -236,14 +239,14 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
position: 'bottom',
data: props.values?.InverterRecord
? {
value: props.values.InverterRecord.ConsumptionPower,
value: totalLoadPower,
unit: 'W'
}
: undefined,
amount: props.values?.InverterRecord
? getAmount(
highestConnectionValue,
props.values.InverterRecord.ConsumptionPower
totalLoadPower
)
: 0,
showValues: showValues
@ -253,23 +256,32 @@ function TopologySodistoreHome(props: TopologySodistoreHomeProps) {
/>
{/*-------------------------------------------------------------------------------------------------------------------------------------------------------------*/}
{Array.from({ length: props.batteryClusterNumber }).map((_, index) => {
const i = index + 1; // battery cluster index starting from 1
let soc: number;
let power: number;
if (hasDevices) {
// Sinexcel: map across devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, 3→D2/B2
const deviceId = String(Math.floor(index / 2) + 1);
const batteryIndex = (index % 2) + 1;
const device = inv?.Devices?.[deviceId];
soc = device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0;
power = device?.[`Battery${batteryIndex}Power`] ?? 0;
} else {
// Growatt: flat Battery1, Battery2, ...
const i = index + 1;
soc = Number(inv?.[`Battery${i}Soc`]) || 0;
power = Number(inv?.[`Battery${i}Power`]) || 0;
}
return (
<TopologyColumn
key={i}
key={index + 1}
centerBox={{
title: `Battery C${i}`,
data: props.values.InverterRecord
title: `Battery C${index + 1}`,
data: inv
? [
{
value: props.values.InverterRecord[`Battery${i}Soc`],
unit: '%'
},
{
value: props.values.InverterRecord[`Battery${i}Power`],
unit: 'W'
}
{ value: soc, unit: '%' },
{ value: power, unit: 'W' }
]
: undefined,
connected: true

View File

@ -177,14 +177,14 @@ function TreeInformation(props: TreeInformationProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
Do you want to delete this folder?
<FormattedMessage id="confirmDeleteFolder" defaultMessage="Do you want to delete this folder?" />
</Typography>
<Typography
variant="body1"
gutterBottom
sx={{ fontSize: '0.875rem' }}
>
All installations of this folder will be deleted.
<FormattedMessage id="deleteFolderWarning" defaultMessage="All installations of this folder will be deleted." />
</Typography>
<div
@ -204,7 +204,7 @@ function TreeInformation(props: TreeInformationProps) {
}}
onClick={deleteFolderModalHandle}
>
Delete
<FormattedMessage id="delete" defaultMessage="Delete" />
</Button>
<Button
sx={{
@ -217,7 +217,7 @@ function TreeInformation(props: TreeInformationProps) {
}}
onClick={deleteFolderModalHandleCancel}
>
Cancel
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>

View File

@ -12,6 +12,7 @@ import {
Typography,
useTheme
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
import { InnovEnergyUser } from 'src/interfaces/UserTypes';
import User from './User';
@ -57,8 +58,8 @@ const FlatUsersView = (props: FlatUsersViewProps) => {
<TableHead>
<TableRow>
<TableCell padding="checkbox"></TableCell>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell><FormattedMessage id="username" defaultMessage="Username" /></TableCell>
<TableCell><FormattedMessage id="email" defaultMessage="Email" /></TableCell>
</TableRow>
</TableHead>
<TableBody>

View File

@ -25,7 +25,7 @@ import axiosConfig from 'src/Resources/axiosConfig';
import { InnovEnergyUser } from 'src/interfaces/UserTypes';
import { TokenContext } from 'src/contexts/tokenContext';
import { TabsContainerWrapper } from 'src/layouts/TabsContainerWrapper';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import UserAccess from '../ManageAccess/UserAccess';
interface singleUserProps {
@ -35,6 +35,7 @@ interface singleUserProps {
function User(props: singleUserProps) {
const theme = useTheme();
const intl = useIntl();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [updated, setUpdated] = useState(false);
@ -43,8 +44,8 @@ function User(props: singleUserProps) {
const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext;
const tabs = [
{ value: 'user', label: 'User' },
{ value: 'manage', label: 'Access Management' }
{ value: 'user', label: intl.formatMessage({ id: 'user' }) },
{ value: 'manage', label: intl.formatMessage({ id: 'accessManagement' }) }
];
const [openModalDeleteUser, setOpenModalDeleteUser] = useState(false);
@ -190,7 +191,7 @@ function User(props: singleUserProps) {
gutterBottom
sx={{ fontWeight: 'bold' }}
>
Do you want to delete this user?
<FormattedMessage id="confirmDeleteUser" defaultMessage="Do you want to delete this user?" />
</Typography>
<div
@ -210,7 +211,7 @@ function User(props: singleUserProps) {
}}
onClick={deleteUserModalHandle}
>
Delete
<FormattedMessage id="delete" defaultMessage="Delete" />
</Button>
<Button
sx={{
@ -223,7 +224,7 @@ function User(props: singleUserProps) {
}}
onClick={deleteUserModalHandleCancel}
>
Cancel
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
</div>
</Box>
@ -274,7 +275,7 @@ function User(props: singleUserProps) {
>
<div>
<TextField
label="Name"
label={intl.formatMessage({ id: 'name' })}
name="name"
value={formValues.name}
onChange={handleChange}
@ -283,7 +284,7 @@ function User(props: singleUserProps) {
</div>
<div>
<TextField
label="Email"
label={intl.formatMessage({ id: 'email' })}
name="email"
value={formValues.email}
onChange={handleChange}
@ -293,7 +294,7 @@ function User(props: singleUserProps) {
</div>
<div>
<TextField
label="Information"
label={intl.formatMessage({ id: 'information' })}
name="information"
value={formValues.information}
onChange={handleChange}

View File

@ -13,11 +13,12 @@ import { AccessContext } from '../../../contexts/AccessContextProvider';
import Button from '@mui/material/Button';
import UserForm from './userForm';
import { UserContext } from '../../../contexts/userContext';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { Close as CloseIcon } from '@mui/icons-material';
import { UserType } from '../../../interfaces/UserTypes';
function UsersSearch() {
const intl = useIntl();
const [searchTerm, setSearchTerm] = useState('');
const { availableUsers, fetchAvailableUsers } = useContext(AccessContext);
const [filteredData, setFilteredData] = useState(availableUsers);
@ -147,7 +148,7 @@ function UsersSearch() {
<Grid item xs={12} md={isMobile ? 5 : 3}>
<FormControl variant="outlined" fullWidth>
<TextField
placeholder="Search"
placeholder={intl.formatMessage({ id: 'search' })}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
fullWidth

View File

@ -18,7 +18,7 @@ import { InnovEnergyUser } from 'src/interfaces/UserTypes';
import axiosConfig from 'src/Resources/axiosConfig';
import { TokenContext } from 'src/contexts/tokenContext';
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
interface userFormProps {
cancel: () => void;
@ -27,10 +27,11 @@ interface userFormProps {
}
function userForm(props: userFormProps) {
const intl = useIntl();
const theme = useTheme();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [errormessage, setErrorMessage] = useState('An error has occured');
const [errormessage, setErrorMessage] = useState(intl.formatMessage({ id: 'errorOccured' }));
const [openInstallation, setOpenInstallation] = useState(false);
const [openFolder, setOpenFolder] = useState(false);
const [formValues, setFormValues] = useState<Partial<InnovEnergyUser>>({
@ -174,7 +175,7 @@ function userForm(props: userFormProps) {
.delete(`/DeleteUser?userId=${res.data.id}`)
.then((response) => {
setLoading(false);
setErrorMessage('An error has occured');
setErrorMessage(intl.formatMessage({ id: 'errorOccured' }));
setError(true);
setTimeout(() => {
props.cancel();

View File

@ -11,6 +11,7 @@ import {
import { Helmet } from 'react-helmet-async';
import RefreshTwoToneIcon from '@mui/icons-material/RefreshTwoTone';
import LoadingButton from '@mui/lab/LoadingButton';
import { FormattedMessage } from 'react-intl';
const GridWrapper = styled(Grid)(
({ theme }) => `
@ -77,7 +78,7 @@ function Status500() {
src="/static/images/status/500.svg"
/>
<Typography variant="h2" sx={{ my: 2 }}>
There was an error, please try again later
<FormattedMessage id="serverError" defaultMessage="There was an error, please try again later" />
</Typography>
<Typography
variant="h4"
@ -85,8 +86,7 @@ function Status500() {
fontWeight="normal"
sx={{ mb: 4 }}
>
The server encountered an internal error and was not able to
complete your request
<FormattedMessage id="serverInternalError" defaultMessage="The server encountered an internal error and was not able to complete your request" />
</Typography>
<LoadingButton
onClick={handleClick}
@ -95,10 +95,10 @@ function Status500() {
color="primary"
startIcon={<RefreshTwoToneIcon />}
>
Refresh view
<FormattedMessage id="refreshView" defaultMessage="Refresh view" />
</LoadingButton>
<Button href="/overview" variant="contained" sx={{ ml: 1 }}>
Go back
<FormattedMessage id="goBack" defaultMessage="Go back" />
</Button>
</Box>
</Container>

View File

@ -122,7 +122,7 @@ const InstallationsContextProvider = ({
useEffect(() => {
const timer = setInterval(() => {
applyBatchUpdates();
}, 60000);
}, 2000);
return () => clearInterval(timer); // Cleanup timer on component unmount
}, [applyBatchUpdates]);
@ -132,7 +132,7 @@ const InstallationsContextProvider = ({
socket.current.close();
socket.current = null;
}
const tokenString = localStorage.getItem('token');
const tokenString = localStorage.getItem('token') || sessionStorage.getItem('token');
const token = tokenString !== null ? tokenString : '';
const urlWithToken = `wss://monitor.inesco.energy/api/CreateWebSocket?authToken=${token}`;
@ -156,8 +156,15 @@ const InstallationsContextProvider = ({
new_socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data); // Parse the JSON data
if (message.id !== -1) {
//For each received message (except the first one which is a batch, call the updateInstallationStatus function in order to import the message to the pendingUpdates list
// Initial batch from backend is an array, subsequent updates are single objects
if (Array.isArray(message)) {
message.forEach((msg) => {
if (msg.id !== -1) {
updateInstallationStatus(msg.id, msg.status, msg.testingMode);
}
});
} else if (message.id !== -1) {
updateInstallationStatus(
message.id,
message.status,

View File

@ -0,0 +1,32 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
interface TourContextType {
runTour: boolean;
startTour: () => void;
stopTour: () => void;
}
const TourContext = createContext<TourContextType>({
runTour: false,
startTour: () => {},
stopTour: () => {}
});
export const useTour = () => useContext(TourContext);
interface TourProviderProps {
children: ReactNode;
}
export function TourProvider({ children }: TourProviderProps) {
const [runTour, setRunTour] = useState(false);
const startTour = useCallback(() => setRunTour(true), []);
const stopTour = useCallback(() => setRunTour(false), []);
return (
<TourContext.Provider value={{ runTour, startTour, stopTour }}>
{children}
</TourContext.Provider>
);
}

View File

@ -3,7 +3,7 @@ import {createContext, ReactNode, useState} from 'react';
// Define the shape of the context
interface TokenContextType {
token?: string | null;
setNewToken: (new_token: string) => void;
setNewToken: (new_token: string, rememberMe?: boolean) => void;
removeToken: () => void;
}
@ -12,20 +12,29 @@ export const TokenContext = createContext<TokenContextType | undefined>(
undefined
);
const getStoredToken = (): string | null => {
// Check localStorage first (rememberMe was checked), then sessionStorage
return localStorage.getItem('token') || sessionStorage.getItem('token');
};
// Create a UserContextProvider component
export const TokenContextProvider = ({ children }: { children: ReactNode }) => {
const searchParams = new URLSearchParams(location.search);
const tokenId = parseInt(searchParams.get('authToken'));
//Initialize context state with a "null" user
const [token, setToken] = useState(localStorage.getItem('token'));
const [token, setToken] = useState(getStoredToken);
const saveToken = (new_token: string) => {
const saveToken = (new_token: string, rememberMe = false) => {
setToken(new_token);
if (rememberMe) {
localStorage.setItem('token', new_token);
sessionStorage.removeItem('token');
} else {
sessionStorage.setItem('token', new_token);
localStorage.removeItem('token');
}
};
const deleteToken = () => {
localStorage.removeItem('token');
sessionStorage.removeItem('token');
setToken(null);
};

View File

@ -4,7 +4,7 @@ import {InnovEnergyUser} from '../interfaces/UserTypes';
// Define the shape of the context
interface UserContextType {
currentUser?: InnovEnergyUser;
setUser: (user: InnovEnergyUser) => void;
setUser: (user: InnovEnergyUser, rememberMe?: boolean) => void;
removeUser: () => void;
}
@ -13,20 +13,26 @@ export const UserContext = createContext<UserContextType | undefined>(
undefined
);
const getStoredUser = (): InnovEnergyUser | null => {
const data = localStorage.getItem('currentUser') || sessionStorage.getItem('currentUser');
return data ? JSON.parse(data) : null;
};
// Create a UserContextProvider component
export const UserContextProvider = ({ children }: { children: ReactNode }) => {
//Initialize context state with a "null" user
const [currentUser, setUser] = useState<InnovEnergyUser>(
JSON.parse(localStorage.getItem('currentUser'))
);
const [currentUser, setUser] = useState<InnovEnergyUser>(getStoredUser);
const saveUser = (new_user: InnovEnergyUser) => {
const saveUser = (new_user: InnovEnergyUser, rememberMe?: boolean) => {
setUser(new_user);
localStorage.setItem('currentUser', JSON.stringify(new_user));
const storage = rememberMe !== undefined
? (rememberMe ? localStorage : sessionStorage)
: (localStorage.getItem('currentUser') ? localStorage : sessionStorage);
storage.setItem('currentUser', JSON.stringify(new_user));
};
const deleteUser = () => {
localStorage.removeItem('currentUser');
sessionStorage.removeItem('currentUser');
};
return (

View File

@ -32,6 +32,22 @@ export class S3Access {
}
}
public list(marker?: string, maxKeys: number = 50): Promise<Response> {
const method = "GET";
const auth = this.createAuthorizationHeader(method, "", "");
const params = new URLSearchParams();
if (marker) params.set("marker", marker);
params.set("max-keys", maxKeys.toString());
const url = this.url + "/" + this.bucket + "/?" + params.toString();
const headers = { Host: this.host, Authorization: auth };
try {
return fetch(url, { method: method, mode: "cors", headers: headers });
} catch {
return Promise.reject();
}
}
private createAuthorizationHeader(
method: string,
s3Path: string,

View File

@ -41,6 +41,7 @@ export interface chartAggregatedDataInterface {
gridImportPower: { name: string; data: number[] };
gridExportPower: { name: string; data: number[] };
heatingPower: { name: string; data: number[] };
acLoad: { name: string; data: number[] };
}
export interface chartDataInterface {
@ -516,36 +517,27 @@ export const transformInputToDailyDataJson = async (
let value: number | undefined = undefined;
if (product === 2) {
// SodioHome: custom extraction with fallbacks for Growatt/Sinexcel
// SodioHome: use top-level aggregated values (Sinexcel multi-inverter)
const inv = result?.InverterRecord;
if (inv) {
switch (category_index) {
case 0: // soc
value = inv.Battery1Soc;
value = inv.AvgBatterySoc ?? inv.Battery1Soc;
break;
case 1: // temperature
// Growatt: Battery1AmbientTemperature, Sinexcel: Battery1Temperature
value = inv.Battery1AmbientTemperature ?? inv.Battery1Temperature;
value = inv.AvgBatteryTemp ?? inv.Battery1AmbientTemperature ?? inv.Battery1Temperature;
break;
case 2: // battery power
value = inv.Battery1Power;
value = inv.TotalBatteryPower ?? inv.Battery1Power;
break;
case 3: // grid power
// Growatt: GridPower (always valid), Sinexcel: GridPower may be 0 when
// electric meter is offline, TotalGridPower is the reliable fallback
value = inv.TotalGridPower ?? inv.GridPower;
break;
case 4: // pv production
// Growatt: PvPower (aggregated), Sinexcel: PvTotalPower or sum PvPower1-4
value =
inv.PvPower ??
inv.PvTotalPower ??
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
.map((key) => inv[key] ?? 0)
.reduce((sum, val) => sum + val, 0);
value = inv.TotalPhotovoltaicPower ?? inv.PvPower ?? inv.PvTotalPower;
break;
case 6: // consumption
value = inv.ConsumptionPower;
value = inv.TotalLoadPower ?? inv.ConsumptionPower;
break;
}
}
@ -613,6 +605,10 @@ export const transformInputToDailyDataJson = async (
'(' + prefixes[chartOverview['pvProduction'].magnitude] + 'W' + ')';
chartOverview.dcBusVoltage.unit =
'(' + prefixes[chartOverview['dcBusVoltage'].magnitude] + 'V' + ')';
chartOverview.ACLoad.unit =
'(' + prefixes[chartOverview['ACLoad'].magnitude] + 'W' + ')';
chartOverview.DCLoad.unit =
'(' + prefixes[chartOverview['DCLoad'].magnitude] + 'W' + ')';
chartOverview.overview = {
magnitude: Math.max(
@ -664,7 +660,8 @@ const fetchJsonDataForOneTime = async (
export const transformInputToAggregatedDataJson = async (
s3Credentials: I_S3Credentials,
start_date: dayjs.Dayjs,
end_date: dayjs.Dayjs
end_date: dayjs.Dayjs,
product?: number
): Promise<{
chartAggregatedData: chartAggregatedDataInterface;
chartOverview: overviewInterface;
@ -685,7 +682,8 @@ export const transformInputToAggregatedDataJson = async (
'ChargingBatteryPower',
'GridImportPower',
'GridExportPower',
'HeatingPower'
'HeatingPower',
'LoadPowerConsumption'
];
const categories = [
@ -707,7 +705,8 @@ export const transformInputToAggregatedDataJson = async (
heatingPower: { name: 'Heating Energy', data: [] },
dcDischargingPower: { name: 'Discharging Battery Energy', data: [] },
gridImportPower: { name: 'Grid Import Energy', data: [] },
gridExportPower: { name: 'Grid Export Energy', data: [] }
gridExportPower: { name: 'Grid Export Energy', data: [] },
acLoad: { name: 'AC Load', data: [] }
};
const chartOverview: overviewInterface = {
@ -736,8 +735,11 @@ export const transformInputToAggregatedDataJson = async (
const timestampPromises = [];
while (currentDay.isBefore(end_date)) {
const dateFormat = product === 2
? currentDay.format('DDMMYYYY')
: currentDay.format('YYYY-MM-DD');
timestampPromises.push(
fetchAggregatedDataJson(currentDay.format('YYYY-MM-DD'), s3Credentials)
fetchAggregatedDataJson(dateFormat, s3Credentials, product)
);
currentDay = currentDay.add(1, 'day');
}
@ -866,6 +868,16 @@ export const transformInputToAggregatedDataJson = async (
max: overviewData['GridImportPower'].max
};
path = 'LoadPowerConsumption';
chartAggregatedData.acLoad.data = data[path];
chartOverview.ACLoad = {
magnitude: overviewData['LoadPowerConsumption'].magnitude,
unit: '(kWh)',
min: overviewData['LoadPowerConsumption'].min,
max: overviewData['LoadPowerConsumption'].max
};
chartOverview.overview = {
magnitude: 0,
unit: '(kWh)',

View File

@ -26,6 +26,7 @@ export interface I_Installation extends I_S3Credentials {
testingMode?: boolean;
status?: number;
serialNumber?: string;
networkProvider: string;
}
export interface I_Folder {

View File

@ -6,6 +6,7 @@
"alarms": "Alarme",
"applyChanges": "Änderungen speichern",
"country": "Land",
"networkProvider": "Netzbetreiber",
"createNewFolder": "Neuer Ordner",
"createNewUser": "Neuer Benutzer",
"customerName": "Kundenname",
@ -106,8 +107,8 @@
"daysOfYourUsage": "Tage Ihres Verbrauchs",
"estMoneySaved": "Geschätzte Ersparnisse",
"atCHFRate": "bei 0,39 CHF/kWh Ø",
"solarCoverage": "Eigenversorgung",
"fromSolarSub": "aus Solar + Batterie",
"solarCoverage": "Energieunabhängigkeit",
"fromSolarSub": "aus eigenem Solar + Batterie System",
"avgDailyConsumption": "Ø Tagesverbrauch",
"batteryEfficiency": "Batterieeffizienz",
"batteryEffSub": "Entladung vs. Ladung",
@ -139,6 +140,22 @@
"weeklyTab": "Wöchentlich",
"monthlyTab": "Monatlich",
"yearlyTab": "Jährlich",
"dailyTab": "Täglich",
"dailyReportTitle": "Tägliche Energieübersicht",
"dailySummary": "Tagesübersicht",
"selectDate": "Datum wählen",
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
"dataUpTo": "Daten bis {date}",
"intradayChart": "Tagesverlauf Energiefluss",
"batteryPower": "Batterieleistung",
"batterySoCLabel": "Batterie SoC",
"powerKw": "Leistung (kW)",
"socPercent": "SoC (%)",
"batteryActivity": "Batterieaktivität",
"batteryCharged": "Batterie geladen",
"batteryDischarged": "Batterie entladen",
"downloadPdf": "PDF herunterladen",
"monthlyReportTitle": "Monatlicher Leistungsbericht",
"yearlyReportTitle": "Jährlicher Leistungsbericht",
"monthlyInsights": "Monatliche Einblicke",
@ -155,6 +172,7 @@
"availableForGeneration": "Zur Generierung verfügbar",
"generateMonth": "{month} {year} generieren ({count} Wochen)",
"generateYear": "{year} generieren ({count} Monate)",
"regenerateReport": "Neu generieren",
"generatingMonthly": "Wird generiert...",
"generatingYearly": "Wird generiert...",
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
@ -164,9 +182,9 @@
"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_test_button": "KI-Diagnose",
"demo_hide_button": "KI-Diagnose ausblenden",
"demo_panel_title": "KI-Diagnose",
"demo_custom_group": "Benutzerdefiniert (kann Mistral KI verwenden)",
"demo_custom_option": "Benutzerdefinierten Alarm eingeben…",
"demo_custom_placeholder": "z.B. UnknownBatteryFault",
@ -404,5 +422,98 @@
"allInstallations": "Alle Installationen",
"group": "Gruppe",
"groups": "Gruppen",
"requiredOrderNumber": "Pflichtbestellnummer"
"requiredOrderNumber": "Pflichtbestellnummer",
"unableToCommunicate": "Kommunikation mit der Installation nicht möglich",
"pleaseWaitOrRefresh": "Bitte warten oder Seite aktualisieren",
"installationOffline": "Die Installation ist derzeit offline. Letzte verfügbare Daten werden angezeigt.",
"noDataForDateRange": "Keine Daten für den gewählten Zeitraum verfügbar. Bitte wählen Sie ein neueres Datum.",
"loginFailed": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"usernameWrong": "Benutzername ist falsch. Bitte versuchen Sie es erneut.",
"mailSentSuccessfully": "E-Mail erfolgreich gesendet.",
"passwordsDoNotMatch": "Passwörter stimmen nicht überein",
"resetPasswordFailed": "Passwort zurücksetzen fehlgeschlagen. Bitte versuchen Sie es erneut.",
"setNewPasswordFailed": "Neues Passwort setzen fehlgeschlagen. Bitte versuchen Sie es erneut.",
"successfullyAppliedConfig": "Konfigurationsdatei erfolgreich angewendet",
"configErrorOccurred": "Ein Fehler ist aufgetreten",
"confirmDeleteFolder": "Möchten Sie diesen Ordner löschen?",
"deleteFolderWarning": "Alle Installationen dieses Ordners werden gelöscht.",
"failedToLoadReport": "Bericht konnte nicht geladen werden. Stellen Sie sicher, dass die Excel-Datei in tmp_report/ abgelegt ist.",
"serverError": "Ein Fehler ist aufgetreten, bitte versuchen Sie es später erneut",
"pvViewNotAvailable": "PV-Ansicht ist derzeit nicht verfügbar",
"batteryServiceNotAvailable": "Batteriedienst ist derzeit nicht verfügbar",
"cannotChangeDateCalibration": "Sie können das Datum nicht ändern, während sich die Installation im Kalibrierungslade-Modus befindet",
"mustSpecifyFutureDate": "Sie müssen ein zukünftiges Datum angeben",
"valueBetween0And100": "Wert muss zwischen 0-100% liegen",
"pleaseProvideValidNumber": "Bitte geben Sie eine gültige Zahl ein",
"stopTimeMustBeLater": "Die Stoppzeit muss nach der Startzeit liegen",
"signIn": "Anmelden",
"username": "Benutzername",
"password": "Passwort",
"rememberMe": "Angemeldet bleiben",
"login": "Anmelden",
"close": "Schliessen",
"forgotPasswordLink": "Passwort vergessen?",
"provideYourUsername": "Geben Sie Ihren Benutzernamen ein",
"userName": "Benutzername",
"resetPassword": "Passwort zurücksetzen",
"setNewPassword": "Neues Passwort setzen",
"verifyPassword": "Passwort bestätigen",
"delete": "Löschen",
"successfullyCreatedUser": "Benutzer erfolgreich erstellt",
"serverInternalError": "Der Server hat einen internen Fehler festgestellt und konnte Ihre Anfrage nicht abschliessen",
"refreshView": "Ansicht aktualisieren",
"goBack": "Zurück",
"connectingToDevice": "Verbindung zum Gerät wird hergestellt...",
"fetchingData": "Daten werden abgerufen...",
"confirmDeleteUser": "Möchten Sie diesen Benutzer löschen?",
"accessManagement": "Zugriffsverwaltung",
"power": "Leistung",
"voltage": "Spannung",
"current": "Strom",
"battery": "Batterie",
"firmware": "Firmware",
"batteryVoltage": "Batteriespannung",
"soc": "Ladezustand",
"soh": "Gesundheitszustand",
"temperature": "Temperatur",
"warnings": "Warnungen",
"alarms": "Alarme",
"minCellVoltage": "Min. Zellenspannung",
"maxCellVoltage": "Max. Zellenspannung",
"voltageDifference": "Spannungsdifferenz",
"pv": "PV",
"showOnly": "Nur anzeigen",
"minimumSocPercent": "Minimaler Ladezustand (%)",
"powerW": "Leistung (W)",
"enterPowerValue": "Positiven oder negativen Leistungswert eingeben",
"startDateTime": "Startdatum und -zeit (Startzeit < Stoppzeit)",
"stopDateTime": "Stoppdatum und -zeit (Startzeit < Stoppzeit)",
"tourLanguageTitle": "Sprache",
"tourLanguageContent": "Wählen Sie Ihre bevorzugte Sprache. Die Oberfläche unterstützt Englisch, Deutsch, Französisch und Italienisch.",
"tourExploreTitle": "Installation erkunden",
"tourExploreContent": "Klicken Sie auf eine Installation, um sie zu öffnen. Klicken Sie darin erneut auf die Tour-Schaltfläche für eine detaillierte Anleitung aller verfügbaren Tabs.",
"tourListTitle": "Installationsliste",
"tourListContent": "Suchen und durchsuchen Sie alle Ihre Installationen. Klicken Sie auf eine Installation, um deren Dashboard zu öffnen.",
"tourTreeTitle": "Ordneransicht",
"tourTreeContent": "Ihre Installationen nach Ordnern organisiert. Erweitern Sie Ordner, um Installationen nach Standort zu finden.",
"tourLiveTitle": "Live-Daten",
"tourLiveContent": "Echtzeitdaten Ihres Systems — Batteriestatus, Energiefluss und Systemstatus, kontinuierlich aktualisiert.",
"tourOverviewTitle": "Überblick",
"tourOverviewContent": "Visuelle Zusammenfassung mit Diagrammen — Produktion, Verbrauch und Batterieladung im Zeitverlauf. Verwenden Sie die Datumssteuerung, um einen bestimmten Tag oder Zeitraum anzuzeigen.",
"tourBatteryviewTitle": "Batterieansicht",
"tourBatteryviewContent": "Detaillierte Batterieüberwachung — Ladezustand (%), Energiefluss (kW), Spannung und Strom pro Batterieeinheit.",
"tourPvviewTitle": "PV-Ansicht",
"tourPvviewContent": "Solaranlagen-Überwachung — Produktionsdaten Ihrer Photovoltaikanlage.",
"tourLogTitle": "Protokoll",
"tourLogContent": "Geräte-Ereignisprotokolle — Systemereignisse, Warnungen und Fehler im Zeitverlauf.",
"tourInformationTitle": "Systeminformationen",
"tourInformationContent": "Installationsdetails — Standort, Geräteseriennummern und Firmware-Versionen. Nutzen Sie dies als Referenz bei Kontakt mit dem Support.",
"tourReportTitle": "Energieberichte",
"tourReportContent": "Energiedaten in kWh anzeigen. Wechseln Sie zwischen wöchentlichen (MontagSonntag), monatlichen und jährlichen Berichten, um zu sehen, wie viel Energie produziert, verbraucht oder gespeichert wurde.",
"tourManageTitle": "Zugriffsverwaltung",
"tourManageContent": "Verwalten Sie, welche Benutzer Zugriff auf diese Installation haben, und legen Sie deren Berechtigungen fest.",
"tourConfigurationTitle": "Konfiguration",
"tourConfigurationContent": "Geräteeinstellungen für diese Installation anzeigen und ändern.",
"tourHistoryTitle": "Verlauf",
"tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert."
}

View File

@ -2,6 +2,7 @@
"allInstallations": "All installations",
"applyChanges": "Apply changes",
"country": "Country",
"networkProvider": "Network Provider",
"customerName": "Customer name",
"english": "English",
"german": "German",
@ -88,8 +89,8 @@
"daysOfYourUsage": "days of your usage",
"estMoneySaved": "Est. Money Saved",
"atCHFRate": "at 0.39 CHF/kWh avg.",
"solarCoverage": "Self-Sufficiency",
"fromSolarSub": "from solar + battery",
"solarCoverage": "Energy Independence",
"fromSolarSub": "from your own solar + battery system",
"avgDailyConsumption": "Avg Daily Consumption",
"batteryEfficiency": "Battery Efficiency",
"batteryEffSub": "discharge vs charge",
@ -121,6 +122,22 @@
"weeklyTab": "Weekly",
"monthlyTab": "Monthly",
"yearlyTab": "Yearly",
"dailyTab": "Daily",
"dailyReportTitle": "Daily Energy Summary",
"dailySummary": "Daily Summary",
"selectDate": "Select Date",
"noDataForDate": "No data available for the selected date.",
"noHourlyData": "Hourly data not available for this day.",
"dataUpTo": "Data up to {date}",
"intradayChart": "Intraday Power Flow",
"batteryPower": "Battery Power",
"batterySoCLabel": "Battery SoC",
"powerKw": "Power (kW)",
"socPercent": "SoC (%)",
"batteryActivity": "Battery Activity",
"batteryCharged": "Battery Charged",
"batteryDischarged": "Battery Discharged",
"downloadPdf": "Download PDF",
"monthlyReportTitle": "Monthly Performance Report",
"yearlyReportTitle": "Annual Performance Report",
"monthlyInsights": "Monthly Insights",
@ -137,6 +154,7 @@
"availableForGeneration": "Available for Generation",
"generateMonth": "Generate {month} {year} ({count} weeks)",
"generateYear": "Generate {year} ({count} months)",
"regenerateReport": "Regenerate",
"generatingMonthly": "Generating...",
"generatingYearly": "Generating...",
"thisMonthWeeklyReports": "This Month's Weekly Reports",
@ -146,11 +164,104 @@
"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_test_button": "AI Diagnosis",
"demo_hide_button": "Hide AI Diagnosis",
"demo_panel_title": "AI Diagnosis",
"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"
"demo_diagnose_button": "Diagnose",
"unableToCommunicate": "Unable to communicate with the installation",
"pleaseWaitOrRefresh": "Please wait or refresh the page",
"installationOffline": "Installation is currently offline. Showing last available data.",
"noDataForDateRange": "No data available for the selected date range. Please choose a more recent date.",
"loginFailed": "Login failed. Please try again.",
"usernameWrong": "Username is wrong. Please try again.",
"mailSentSuccessfully": "Mail sent successfully.",
"passwordsDoNotMatch": "Passwords do not match",
"resetPasswordFailed": "Reset Password failed. Please try again.",
"setNewPasswordFailed": "Setting new password failed. Please try again.",
"successfullyAppliedConfig": "Successfully applied configuration file",
"configErrorOccurred": "An error has occurred",
"confirmDeleteFolder": "Do you want to delete this folder?",
"deleteFolderWarning": "All installations of this folder will be deleted.",
"failedToLoadReport": "Failed to load report. Make sure the Excel file is placed in tmp_report/",
"serverError": "There was an error, please try again later",
"pvViewNotAvailable": "Pv view is not available at the moment",
"batteryServiceNotAvailable": "Battery service is not available at the moment",
"cannotChangeDateCalibration": "You cannot change the date while the installation is in Calibration Charge Mode",
"mustSpecifyFutureDate": "You must specify a future date",
"valueBetween0And100": "Value should be between 0-100%",
"pleaseProvideValidNumber": "Please provide a valid number",
"stopTimeMustBeLater": "Stop time must be later than start time",
"signIn": "Sign in",
"username": "Username",
"password": "Password",
"rememberMe": "Remember me",
"login": "Login",
"close": "Close",
"forgotPasswordLink": "Forgot password?",
"provideYourUsername": "Provide your username",
"userName": "User Name",
"resetPassword": "Reset Password",
"setNewPassword": "Set New Password",
"verifyPassword": "Verify Password",
"delete": "Delete",
"successfullyCreatedUser": "Successfully Created User",
"serverInternalError": "The server encountered an internal error and was not able to complete your request",
"refreshView": "Refresh view",
"goBack": "Go back",
"connectingToDevice": "Connecting to the device...",
"fetchingData": "Fetching data...",
"confirmDeleteUser": "Do you want to delete this user?",
"accessManagement": "Access Management",
"power": "Power",
"voltage": "Voltage",
"current": "Current",
"battery": "Battery",
"firmware": "Firmware",
"batteryVoltage": "Battery Voltage",
"soc": "SoC",
"soh": "SoH",
"temperature": "Temperature",
"warnings": "Warnings",
"alarms": "Alarms",
"minCellVoltage": "Min Cell Voltage",
"maxCellVoltage": "Max Cell Voltage",
"voltageDifference": "Voltage Difference",
"pv": "Pv",
"showOnly": "Show Only",
"minimumSocPercent": "Minimum SoC (%)",
"powerW": "Power (W)",
"enterPowerValue": "Enter a positive or negative power value",
"startDateTime": "Start Date and Time (Start Time < Stop Time)",
"stopDateTime": "Stop Date and Time (Start Time < Stop Time)",
"tourLanguageTitle": "Language",
"tourLanguageContent": "Choose your preferred language. The interface supports English, German, French, and Italian.",
"tourExploreTitle": "Explore an Installation",
"tourExploreContent": "Click any installation to open it. Once inside, click the tour button again for a detailed guide of all available tabs.",
"tourListTitle": "Installation List",
"tourListContent": "Search and browse all your installations. Click any installation to open its dashboard.",
"tourTreeTitle": "Folder View",
"tourTreeContent": "Your installations organised in folders. Expand folders to find installations by site or location.",
"tourLiveTitle": "Live Data",
"tourLiveContent": "Real-time data from your system — battery state, power flow, and system status, updated continuously.",
"tourOverviewTitle": "Overview",
"tourOverviewContent": "Visual summary with charts — production, consumption, and battery charge over time. Use the date controls to view a specific day or custom range.",
"tourBatteryviewTitle": "Battery View",
"tourBatteryviewContent": "Detailed battery monitoring — state of charge (%), power flow (kW), voltage, and current per battery unit.",
"tourPvviewTitle": "PV View",
"tourPvviewContent": "Solar panel monitoring — see production data from your photovoltaic system.",
"tourLogTitle": "Log",
"tourLogContent": "Device event logs — view system events, warnings, and errors over time.",
"tourInformationTitle": "System Information",
"tourInformationContent": "Installation details — location, device serial numbers, and firmware versions. Use this as reference if you contact support.",
"tourReportTitle": "Energy Reports",
"tourReportContent": "View energy data in kWh. Switch between weekly (MondaySunday), monthly, and yearly reports to see how much energy was produced, consumed, or stored.",
"tourManageTitle": "Access Management",
"tourManageContent": "Manage which users have access to this installation and set their permissions.",
"tourConfigurationTitle": "Configuration",
"tourConfigurationContent": "View and modify device settings for this installation.",
"tourHistoryTitle": "History",
"tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when."
}

View File

@ -4,6 +4,7 @@
"alarms": "Alarmes",
"applyChanges": "Appliquer",
"country": "Pays",
"networkProvider": "Gestionnaire de réseau",
"createNewFolder": "Nouveau dossier",
"createNewUser": "Nouvel utilisateur",
"customerName": "Nom du client",
@ -100,8 +101,8 @@
"daysOfYourUsage": "jours de votre consommation",
"estMoneySaved": "Économies estimées",
"atCHFRate": "à 0,39 CHF/kWh moy.",
"solarCoverage": "Autosuffisance",
"fromSolarSub": "du solaire + batterie",
"solarCoverage": "Indépendance énergétique",
"fromSolarSub": "de votre système solaire + batterie",
"avgDailyConsumption": "Conso. quotidienne moy.",
"batteryEfficiency": "Efficacité de la batterie",
"batteryEffSub": "décharge vs charge",
@ -133,6 +134,22 @@
"weeklyTab": "Hebdomadaire",
"monthlyTab": "Mensuel",
"yearlyTab": "Annuel",
"dailyTab": "Quotidien",
"dailyReportTitle": "Résumé énergétique quotidien",
"dailySummary": "Résumé du jour",
"selectDate": "Sélectionner la date",
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
"noHourlyData": "Données horaires non disponibles pour ce jour.",
"dataUpTo": "Données jusqu'au {date}",
"intradayChart": "Flux d'énergie journalier",
"batteryPower": "Puissance batterie",
"batterySoCLabel": "SoC batterie",
"powerKw": "Puissance (kW)",
"socPercent": "SoC (%)",
"batteryActivity": "Activité de la batterie",
"batteryCharged": "Batterie chargée",
"batteryDischarged": "Batterie déchargée",
"downloadPdf": "Télécharger PDF",
"monthlyReportTitle": "Rapport de performance mensuel",
"yearlyReportTitle": "Rapport de performance annuel",
"monthlyInsights": "Aperçus mensuels",
@ -149,6 +166,7 @@
"availableForGeneration": "Disponible pour génération",
"generateMonth": "Générer {month} {year} ({count} semaines)",
"generateYear": "Générer {year} ({count} mois)",
"regenerateReport": "Régénérer",
"generatingMonthly": "Génération en cours...",
"generatingYearly": "Génération en cours...",
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
@ -158,9 +176,9 @@
"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_test_button": "Diagnostic IA",
"demo_hide_button": "Masquer le diagnostic IA",
"demo_panel_title": "Diagnostic IA",
"demo_custom_group": "Personnalisé (peut utiliser Mistral IA)",
"demo_custom_option": "Saisir une alarme personnalisée…",
"demo_custom_placeholder": "ex. UnknownBatteryFault",
@ -404,5 +422,98 @@
"groupTabs": "Groupes",
"groupTree": "Arborescence de groupes",
"installationTabs": "Installations",
"navigationTabs": "Navigation"
"navigationTabs": "Navigation",
"unableToCommunicate": "Impossible de communiquer avec l'installation",
"pleaseWaitOrRefresh": "Veuillez patienter ou actualiser la page",
"installationOffline": "L'installation est actuellement hors ligne. Affichage des dernières données disponibles.",
"noDataForDateRange": "Aucune donnée disponible pour la période sélectionnée. Veuillez choisir une date plus récente.",
"loginFailed": "Échec de la connexion. Veuillez réessayer.",
"usernameWrong": "Nom d'utilisateur incorrect. Veuillez réessayer.",
"mailSentSuccessfully": "E-mail envoyé avec succès.",
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
"resetPasswordFailed": "La réinitialisation du mot de passe a échoué. Veuillez réessayer.",
"setNewPasswordFailed": "La définition du nouveau mot de passe a échoué. Veuillez réessayer.",
"successfullyAppliedConfig": "Fichier de configuration appliqué avec succès",
"configErrorOccurred": "Une erreur s'est produite",
"confirmDeleteFolder": "Voulez-vous supprimer ce dossier ?",
"deleteFolderWarning": "Toutes les installations de ce dossier seront supprimées.",
"failedToLoadReport": "Impossible de charger le rapport. Assurez-vous que le fichier Excel est placé dans tmp_report/",
"serverError": "Une erreur s'est produite, veuillez réessayer plus tard",
"pvViewNotAvailable": "La vue PV n'est pas disponible pour le moment",
"batteryServiceNotAvailable": "Le service batterie n'est pas disponible pour le moment",
"cannotChangeDateCalibration": "Vous ne pouvez pas changer la date pendant que l'installation est en mode de charge de calibration",
"mustSpecifyFutureDate": "Vous devez spécifier une date future",
"valueBetween0And100": "La valeur doit être entre 0-100%",
"pleaseProvideValidNumber": "Veuillez fournir un nombre valide",
"stopTimeMustBeLater": "L'heure d'arrêt doit être postérieure à l'heure de début",
"signIn": "Se connecter",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"rememberMe": "Se souvenir de moi",
"login": "Connexion",
"close": "Fermer",
"forgotPasswordLink": "Mot de passe oublié ?",
"provideYourUsername": "Entrez votre nom d'utilisateur",
"userName": "Nom d'utilisateur",
"resetPassword": "Réinitialiser le mot de passe",
"setNewPassword": "Définir un nouveau mot de passe",
"verifyPassword": "Vérifier le mot de passe",
"delete": "Supprimer",
"successfullyCreatedUser": "Utilisateur créé avec succès",
"serverInternalError": "Le serveur a rencontré une erreur interne et n'a pas pu traiter votre demande",
"refreshView": "Actualiser la vue",
"goBack": "Retour",
"connectingToDevice": "Connexion à l'appareil en cours...",
"fetchingData": "Récupération des données...",
"confirmDeleteUser": "Voulez-vous supprimer cet utilisateur ?",
"accessManagement": "Gestion des accès",
"power": "Puissance",
"voltage": "Tension",
"current": "Courant",
"battery": "Batterie",
"firmware": "Firmware",
"batteryVoltage": "Tension de la batterie",
"soc": "État de charge",
"soh": "État de santé",
"temperature": "Température",
"warnings": "Avertissements",
"alarms": "Alarmes",
"minCellVoltage": "Tension min. cellule",
"maxCellVoltage": "Tension max. cellule",
"voltageDifference": "Différence de tension",
"pv": "PV",
"showOnly": "Afficher uniquement",
"minimumSocPercent": "SoC minimum (%)",
"powerW": "Puissance (W)",
"enterPowerValue": "Entrez une valeur de puissance positive ou négative",
"startDateTime": "Date et heure de début (Début < Fin)",
"stopDateTime": "Date et heure de fin (Début < Fin)",
"tourLanguageTitle": "Langue",
"tourLanguageContent": "Choisissez votre langue préférée. L'interface est disponible en anglais, allemand, français et italien.",
"tourExploreTitle": "Explorer une installation",
"tourExploreContent": "Cliquez sur une installation pour l'ouvrir. Une fois à l'intérieur, cliquez à nouveau sur le bouton de visite pour un guide détaillé de tous les onglets disponibles.",
"tourListTitle": "Liste des installations",
"tourListContent": "Recherchez et parcourez toutes vos installations. Cliquez sur une installation pour ouvrir son tableau de bord.",
"tourTreeTitle": "Vue par dossiers",
"tourTreeContent": "Vos installations organisées en dossiers. Développez les dossiers pour trouver les installations par site ou emplacement.",
"tourLiveTitle": "Données en direct",
"tourLiveContent": "Données en temps réel de votre système — état de la batterie, flux d'énergie et état du système, mis à jour en continu.",
"tourOverviewTitle": "Aperçu",
"tourOverviewContent": "Résumé visuel avec graphiques — production, consommation et charge de la batterie au fil du temps. Utilisez les contrôles de date pour afficher un jour spécifique ou une plage personnalisée.",
"tourBatteryviewTitle": "Vue batterie",
"tourBatteryviewContent": "Surveillance détaillée de la batterie — état de charge (%), flux d'énergie (kW), tension et courant par unité de batterie.",
"tourPvviewTitle": "Vue PV",
"tourPvviewContent": "Surveillance des panneaux solaires — consultez les données de production de votre système photovoltaïque.",
"tourLogTitle": "Journal",
"tourLogContent": "Journaux d'événements — événements système, avertissements et erreurs au fil du temps.",
"tourInformationTitle": "Informations système",
"tourInformationContent": "Détails de l'installation — emplacement, numéros de série des appareils et versions du firmware. Utilisez ceci comme référence si vous contactez le support.",
"tourReportTitle": "Rapports énergétiques",
"tourReportContent": "Afficher les données énergétiques en kWh. Basculez entre les rapports hebdomadaires (lundidimanche), mensuels et annuels pour voir combien d'énergie a été produite, consommée ou stockée.",
"tourManageTitle": "Gestion des accès",
"tourManageContent": "Gérez quels utilisateurs ont accès à cette installation et définissez leurs autorisations.",
"tourConfigurationTitle": "Configuration",
"tourConfigurationContent": "Afficher et modifier les paramètres de l'appareil pour cette installation.",
"tourHistoryTitle": "Historique",
"tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand."
}

View File

@ -2,6 +2,7 @@
"allInstallations": "Tutte le installazioni",
"applyChanges": "Applica modifiche",
"country": "Paese",
"networkProvider": "Gestore di rete",
"customerName": "Nome cliente",
"english": "Inglese",
"german": "Tedesco",
@ -111,8 +112,8 @@
"daysOfYourUsage": "giorni del tuo consumo",
"estMoneySaved": "Risparmio stimato",
"atCHFRate": "a 0,39 CHF/kWh media",
"solarCoverage": "Autosufficienza",
"fromSolarSub": "da solare + batteria",
"solarCoverage": "Indipendenza energetica",
"fromSolarSub": "dal proprio impianto solare + batteria",
"avgDailyConsumption": "Consumo medio giornaliero",
"batteryEfficiency": "Efficienza della batteria",
"batteryEffSub": "scarica vs carica",
@ -144,6 +145,22 @@
"weeklyTab": "Settimanale",
"monthlyTab": "Mensile",
"yearlyTab": "Annuale",
"dailyTab": "Giornaliero",
"dailyReportTitle": "Riepilogo energetico giornaliero",
"dailySummary": "Riepilogo del giorno",
"selectDate": "Seleziona data",
"noDataForDate": "Nessun dato disponibile per la data selezionata.",
"noHourlyData": "Dati orari non disponibili per questo giorno.",
"dataUpTo": "Dati fino al {date}",
"intradayChart": "Flusso energetico giornaliero",
"batteryPower": "Potenza batteria",
"batterySoCLabel": "SoC batteria",
"powerKw": "Potenza (kW)",
"socPercent": "SoC (%)",
"batteryActivity": "Attività della batteria",
"batteryCharged": "Batteria caricata",
"batteryDischarged": "Batteria scaricata",
"downloadPdf": "Scarica PDF",
"monthlyReportTitle": "Rapporto mensile sulle prestazioni",
"yearlyReportTitle": "Rapporto annuale sulle prestazioni",
"monthlyInsights": "Approfondimenti mensili",
@ -160,6 +177,7 @@
"availableForGeneration": "Disponibile per la generazione",
"generateMonth": "Genera {month} {year} ({count} settimane)",
"generateYear": "Genera {year} ({count} mesi)",
"regenerateReport": "Rigenera",
"generatingMonthly": "Generazione in corso...",
"generatingYearly": "Generazione in corso...",
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
@ -169,9 +187,9 @@
"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_test_button": "Diagnosi IA",
"demo_hide_button": "Nascondi diagnosi IA",
"demo_panel_title": "Diagnosi IA",
"demo_custom_group": "Personalizzato (potrebbe usare Mistral IA)",
"demo_custom_option": "Inserisci allarme personalizzato…",
"demo_custom_placeholder": "es. UnknownBatteryFault",
@ -404,5 +422,98 @@
"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"
"alarm_OffGridBusVoltageTooLow": "Tensione Bus Fuori Rete Troppo Bassa",
"unableToCommunicate": "Impossibile comunicare con l'installazione",
"pleaseWaitOrRefresh": "Attendere o aggiornare la pagina",
"installationOffline": "L'installazione è attualmente offline. Vengono mostrati gli ultimi dati disponibili.",
"noDataForDateRange": "Nessun dato disponibile per il periodo selezionato. Scegliere una data più recente.",
"loginFailed": "Accesso fallito. Riprovare.",
"usernameWrong": "Nome utente errato. Riprovare.",
"mailSentSuccessfully": "E-mail inviata con successo.",
"passwordsDoNotMatch": "Le password non corrispondono",
"resetPasswordFailed": "Reimpostazione password fallita. Riprovare.",
"setNewPasswordFailed": "Impostazione nuova password fallita. Riprovare.",
"successfullyAppliedConfig": "File di configurazione applicato con successo",
"configErrorOccurred": "Si è verificato un errore",
"confirmDeleteFolder": "Vuoi eliminare questa cartella?",
"deleteFolderWarning": "Tutte le installazioni di questa cartella verranno eliminate.",
"failedToLoadReport": "Impossibile caricare il rapporto. Assicurarsi che il file Excel sia in tmp_report/",
"serverError": "Si è verificato un errore, riprovare più tardi",
"pvViewNotAvailable": "La vista PV non è disponibile al momento",
"batteryServiceNotAvailable": "Il servizio batteria non è disponibile al momento",
"cannotChangeDateCalibration": "Non è possibile cambiare la data mentre l'installazione è in modalità di carica di calibrazione",
"mustSpecifyFutureDate": "Specificare una data futura",
"valueBetween0And100": "Il valore deve essere tra 0-100%",
"pleaseProvideValidNumber": "Inserire un numero valido",
"stopTimeMustBeLater": "L'ora di fine deve essere successiva all'ora di inizio",
"signIn": "Accedi",
"username": "Nome utente",
"password": "Password",
"rememberMe": "Ricordami",
"login": "Accedi",
"close": "Chiudi",
"forgotPasswordLink": "Password dimenticata?",
"provideYourUsername": "Inserisci il tuo nome utente",
"userName": "Nome utente",
"resetPassword": "Reimposta password",
"setNewPassword": "Imposta nuova password",
"verifyPassword": "Verifica password",
"delete": "Elimina",
"successfullyCreatedUser": "Utente creato con successo",
"serverInternalError": "Il server ha riscontrato un errore interno e non è stato in grado di completare la richiesta",
"refreshView": "Aggiorna vista",
"goBack": "Indietro",
"connectingToDevice": "Connessione al dispositivo in corso...",
"fetchingData": "Recupero dati in corso...",
"confirmDeleteUser": "Vuoi eliminare questo utente?",
"accessManagement": "Gestione accessi",
"power": "Potenza",
"voltage": "Tensione",
"current": "Corrente",
"battery": "Batteria",
"firmware": "Firmware",
"batteryVoltage": "Tensione batteria",
"soc": "Stato di carica",
"soh": "Stato di salute",
"temperature": "Temperatura",
"warnings": "Avvisi",
"alarms": "Allarmi",
"minCellVoltage": "Tensione min. cella",
"maxCellVoltage": "Tensione max. cella",
"voltageDifference": "Differenza di tensione",
"pv": "PV",
"showOnly": "Mostra solo",
"minimumSocPercent": "SoC minimo (%)",
"powerW": "Potenza (W)",
"enterPowerValue": "Inserire un valore di potenza positivo o negativo",
"startDateTime": "Data e ora di inizio (Inizio < Fine)",
"stopDateTime": "Data e ora di fine (Inizio < Fine)",
"tourLanguageTitle": "Lingua",
"tourLanguageContent": "Scegli la tua lingua preferita. L'interfaccia supporta inglese, tedesco, francese e italiano.",
"tourExploreTitle": "Esplora un'installazione",
"tourExploreContent": "Clicca su un'installazione per aprirla. Una volta dentro, clicca nuovamente sul pulsante del tour per una guida dettagliata di tutte le schede disponibili.",
"tourListTitle": "Elenco installazioni",
"tourListContent": "Cerca e sfoglia tutte le tue installazioni. Clicca su un'installazione per aprire la sua dashboard.",
"tourTreeTitle": "Vista cartelle",
"tourTreeContent": "Le tue installazioni organizzate in cartelle. Espandi le cartelle per trovare le installazioni per sito o posizione.",
"tourLiveTitle": "Dati in tempo reale",
"tourLiveContent": "Dati in tempo reale dal tuo sistema — stato della batteria, flusso di energia e stato del sistema, aggiornati continuamente.",
"tourOverviewTitle": "Panoramica",
"tourOverviewContent": "Riepilogo visivo con grafici — produzione, consumo e carica della batteria nel tempo. Usa i controlli della data per visualizzare un giorno specifico o un intervallo personalizzato.",
"tourBatteryviewTitle": "Vista batteria",
"tourBatteryviewContent": "Monitoraggio dettagliato della batteria — stato di carica (%), flusso di energia (kW), tensione e corrente per unità di batteria.",
"tourPvviewTitle": "Vista PV",
"tourPvviewContent": "Monitoraggio dei pannelli solari — visualizza i dati di produzione del tuo impianto fotovoltaico.",
"tourLogTitle": "Registro",
"tourLogContent": "Registri degli eventi — eventi di sistema, avvisi ed errori nel tempo.",
"tourInformationTitle": "Informazioni di sistema",
"tourInformationContent": "Dettagli dell'installazione — posizione, numeri di serie dei dispositivi e versioni firmware. Usa come riferimento se contatti l'assistenza.",
"tourReportTitle": "Rapporti energetici",
"tourReportContent": "Visualizza i dati energetici in kWh. Passa tra rapporti settimanali (lunedìdomenica), mensili e annuali per vedere quanta energia è stata prodotta, consumata o immagazzinata.",
"tourManageTitle": "Gestione accessi",
"tourManageContent": "Gestisci quali utenti hanno accesso a questa installazione e imposta i loro permessi.",
"tourConfigurationTitle": "Configurazione",
"tourConfigurationContent": "Visualizza e modifica le impostazioni del dispositivo per questa installazione.",
"tourHistoryTitle": "Cronologia",
"tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando."
}

View File

@ -112,6 +112,7 @@ function HeaderMenu(props: HeaderButtonsProps) {
sx={{
color: isMobile ? 'white' : ''
}}
data-tour="language-selector"
>
<List disablePadding component={Box} display="flex">
<ListItem

View File

@ -13,10 +13,12 @@ import {
useTheme
} from '@mui/material';
import MenuTwoToneIcon from '@mui/icons-material/MenuTwoTone';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
import { SidebarContext } from 'src/contexts/SidebarContext';
import CloseTwoToneIcon from '@mui/icons-material/CloseTwoTone';
import HeaderUserbox from './Userbox';
import HeaderMenu from './Menu';
import { useTour } from 'src/contexts/TourContext';
const HeaderWrapper = styled(Box)(
({ theme }) => `
@ -44,6 +46,7 @@ interface HeaderProps {
function Header(props: HeaderProps) {
const { sidebarToggle, toggleSidebar } = useContext(SidebarContext);
const { startTour } = useTour();
const theme = useTheme();
const isMobile = window.innerWidth <= 1280;
@ -96,6 +99,14 @@ function Header(props: HeaderProps) {
></Stack>
<Box display="flex" alignItems="center">
<Tooltip arrow title="Take a Tour">
<IconButton
sx={{ color: isMobile ? 'white' : theme.header.textColor }}
onClick={startTour}
>
<HelpOutlineIcon />
</IconButton>
</Tooltip>
<HeaderMenu
language={props.language}
onSelectLanguage={props.onSelectLanguage}

View File

@ -1,6 +1,17 @@
import { ReactNode } from 'react';
import { ReactNode, useContext, useEffect, useState } from 'react';
import { alpha, Box, lighten, useTheme } from '@mui/material';
import { Outlet } from 'react-router-dom';
import { Outlet, useLocation } from 'react-router-dom';
import Joyride, { CallBackProps, STATUS, Step } from 'react-joyride';
import { useIntl, IntlShape } from 'react-intl';
import { useTour } from 'src/contexts/TourContext';
import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes';
import {
buildSodiohomeCustomerTourSteps, buildSodiohomePartnerTourSteps, buildSodiohomeAdminTourSteps,
buildSalimaxCustomerTourSteps, buildSalimaxPartnerTourSteps, buildSalimaxAdminTourSteps,
buildSodistoregridCustomerTourSteps, buildSodistoregridPartnerTourSteps, buildSodistoregridAdminTourSteps,
buildSalidomoCustomerTourSteps, buildSalidomoPartnerTourSteps, buildSalidomoAdminTourSteps
} from 'src/config/tourSteps';
import Sidebar from './Sidebar';
import Header from './Header';
@ -11,11 +22,88 @@ interface SidebarLayoutProps {
onSelectLanguage: (item: string) => void;
}
function getTourSteps(pathname: string, userType: UserType, intl: IntlShape, isInsideInstallation: boolean): Step[] {
const role = userType === UserType.admin ? 'admin'
: userType === UserType.partner ? 'partner'
: 'customer';
if (pathname.includes('/sodiohome_installations')) {
if (role === 'admin') return buildSodiohomeAdminTourSteps(intl, isInsideInstallation);
if (role === 'partner') return buildSodiohomePartnerTourSteps(intl, isInsideInstallation);
return buildSodiohomeCustomerTourSteps(intl, isInsideInstallation);
}
if (pathname.includes('/salidomo_installations')) {
if (role === 'admin') return buildSalidomoAdminTourSteps(intl, isInsideInstallation);
if (role === 'partner') return buildSalidomoPartnerTourSteps(intl, isInsideInstallation);
return buildSalidomoCustomerTourSteps(intl, isInsideInstallation);
}
if (pathname.includes('/sodistoregrid_installations')) {
if (role === 'admin') return buildSodistoregridAdminTourSteps(intl, isInsideInstallation);
if (role === 'partner') return buildSodistoregridPartnerTourSteps(intl, isInsideInstallation);
return buildSodistoregridCustomerTourSteps(intl, isInsideInstallation);
}
// Salimax (/installations/) and Sodistore Max (/sodistore_installations/)
if (role === 'admin') return buildSalimaxAdminTourSteps(intl, isInsideInstallation);
if (role === 'partner') return buildSalimaxPartnerTourSteps(intl, isInsideInstallation);
return buildSalimaxCustomerTourSteps(intl, isInsideInstallation);
}
const SidebarLayout = (props: SidebarLayoutProps) => {
const theme = useTheme();
const intl = useIntl();
const { runTour, stopTour } = useTour();
const location = useLocation();
const { currentUser } = useContext(UserContext);
const [tourSteps, setTourSteps] = useState<Step[]>([]);
const [tourReady, setTourReady] = useState(false);
useEffect(() => {
if (!runTour) {
setTourReady(false);
return;
}
// Delay to let child components render their tour target elements
const timer = setTimeout(() => {
const userType = currentUser?.userType ?? UserType.client;
const isInsideInstallation = location.pathname.includes('/installation/');
const steps = getTourSteps(location.pathname, userType, intl, isInsideInstallation);
const filtered = steps.filter((step) => {
if (typeof step.target === 'string') {
return document.querySelector(step.target) !== null;
}
return true;
});
setTourSteps(filtered);
setTourReady(true);
}, 300);
return () => clearTimeout(timer);
}, [runTour, location.pathname, currentUser?.userType, intl]);
const handleJoyrideCallback = (data: CallBackProps) => {
const { status } = data;
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
stopTour();
}
};
return (
<>
<Joyride
steps={tourSteps}
run={tourReady}
callback={handleJoyrideCallback}
continuous
showSkipButton
showProgress
scrollToFirstStep
disableOverlayClose
styles={{
options: {
primaryColor: '#ffc04d',
zIndex: 10000
}
}}
/>
<Box
sx={{
flex: 1,