Merge branch 'main' into sinexcel_multiinveters_configurtaion

This commit is contained in:
Yinyin Liu 2026-04-13 09:54:09 +02:00
commit e706caf390
98 changed files with 4796 additions and 1740 deletions

View File

@ -9,20 +9,21 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v3 uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- run: echo " The ${{ gitea.repository }} repository has been cloned to the runner." - run: echo " The ${{ gitea.repository }} repository has been cloned to the runner."
- uses: actions/setup-dotnet@v3 - uses: actions/setup-dotnet@55ec9447dda3d1cf6bd587150f3262f30ee10815 # v3
with: with:
dotnet-version: '7.0.x' dotnet-version: '7.0.x'
- run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false - run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false
- uses: actions/setup-node@v3 - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3
- run: npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 audit --audit-level=moderate || true
- run: | - run: |
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install --ignore-scripts
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build
- name: stop services - name: stop services
uses: appleboy/ssh-action@v0.1.4 uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
with: with:
host: 194.182.190.208 host: 194.182.190.208
username: ubuntu username: ubuntu
@ -31,7 +32,7 @@ jobs:
sudo systemctl stop backend sudo systemctl stop backend
- name: Copy Backend - name: Copy Backend
uses: appleboy/scp-action@v0.1.4 uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
with: with:
host: 194.182.190.208 host: 194.182.190.208
username: ubuntu username: ubuntu
@ -42,7 +43,7 @@ jobs:
strip_components: 1 strip_components: 1
- name: Copy Frontend - name: Copy Frontend
uses: appleboy/scp-action@v0.1.4 uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
with: with:
host: 194.182.190.208 host: 194.182.190.208
username: ubuntu username: ubuntu
@ -53,7 +54,7 @@ jobs:
strip_components: 1 strip_components: 1
- name: restart services - name: restart services
uses: appleboy/ssh-action@v0.1.4 uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
with: with:
host: 194.182.190.208 host: 194.182.190.208
username: ubuntu username: ubuntu
@ -61,4 +62,3 @@ jobs:
script: | script: |
sudo systemctl restart backend sudo systemctl restart backend
sudo cp -rf ~/frontend/build/* /var/www/html/monitor.innov.energy/html/ sudo cp -rf ~/frontend/build/* /var/www/html/monitor.innov.energy/html/
sudo npm install -g serve

View File

@ -9,19 +9,20 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v3 uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- run: echo " The ${{ gitea.repository }} repository has been cloned to the runner." - run: echo " The ${{ gitea.repository }} repository has been cloned to the runner."
- uses: actions/setup-dotnet@v3 - uses: actions/setup-dotnet@55ec9447dda3d1cf6bd587150f3262f30ee10815 # v3
with: with:
dotnet-version: '7.0.x' dotnet-version: '7.0.x'
- run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false - run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false
- uses: actions/setup-node@v3 - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3
- run: npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 audit --audit-level=moderate || true
- run: | - run: |
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install --ignore-scripts
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build
- name: stop services - name: stop services
uses: appleboy/ssh-action@v0.1.4 uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
with: with:
host: 91.92.154.141 host: 91.92.154.141
username: ubuntu username: ubuntu
@ -30,7 +31,7 @@ jobs:
sudo systemctl stop backend sudo systemctl stop backend
- name: Copy Backend - name: Copy Backend
uses: appleboy/scp-action@v0.1.4 uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
with: with:
host: 91.92.154.141 host: 91.92.154.141
username: ubuntu username: ubuntu
@ -41,7 +42,7 @@ jobs:
strip_components: 11 strip_components: 11
- name: Copy Frontend - name: Copy Frontend
uses: appleboy/scp-action@v0.1.4 uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
with: with:
host: 91.92.154.141 host: 91.92.154.141
username: ubuntu username: ubuntu
@ -52,7 +53,7 @@ jobs:
strip_components: 5 strip_components: 5
- name: restart services - name: restart services
uses: appleboy/ssh-action@v0.1.4 uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
with: with:
host: 91.92.154.141 host: 91.92.154.141
username: ubuntu username: ubuntu
@ -60,4 +61,3 @@ jobs:
script: | script: |
sudo systemctl restart backend sudo systemctl restart backend
sudo cp -rf ~/frontend/build/* /var/www/html/stage.innov.energy/html/ sudo cp -rf ~/frontend/build/* /var/www/html/stage.innov.energy/html/
sudo npm install -g serve

View File

@ -7,6 +7,8 @@ using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.App.Backend.Relations; using InnovEnergy.App.Backend.Relations;
using InnovEnergy.App.Backend.Services; using InnovEnergy.App.Backend.Services;
using InnovEnergy.App.Backend.Websockets; using InnovEnergy.App.Backend.Websockets;
using InnovEnergy.Lib.S3Utils;
using InnovEnergy.Lib.S3Utils.DataTypes;
using InnovEnergy.Lib.Utils; using InnovEnergy.Lib.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -202,6 +204,8 @@ public class Controller : ControllerBase
bucketPath = "s3://" + installation.S3BucketId + "-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa/" + startTimestamp; bucketPath = "s3://" + installation.S3BucketId + "-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa/" + startTimestamp;
else if (installation.Product == (int)ProductType.SodistoreGrid) else if (installation.Product == (int)ProductType.SodistoreGrid)
bucketPath = "s3://" + installation.S3BucketId + "-5109c126-e141-43ab-8658-f3c44c838ae8/" + startTimestamp; bucketPath = "s3://" + installation.S3BucketId + "-5109c126-e141-43ab-8658-f3c44c838ae8/" + startTimestamp;
else if (installation.Product == (int)ProductType.SodistorePro)
bucketPath = "s3://" + installation.S3BucketId + "-325c9373-9025-4a8d-bf5a-f9eedf1f155c/" + startTimestamp;
else else
bucketPath = "s3://" + installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/" + startTimestamp; bucketPath = "s3://" + installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/" + startTimestamp;
Console.WriteLine("Fetching data for "+startTimestamp); Console.WriteLine("Fetching data for "+startTimestamp);
@ -815,9 +819,10 @@ public class Controller : ControllerBase
if (installation is null || !user.HasAccessTo(installation)) if (installation is null || !user.HasAccessTo(installation))
return Unauthorized(); return Unauthorized();
// AI diagnostics are scoped to SodistoreHome and SodiStoreMax only // AI diagnostics are scoped to SodistoreHome, SodiStoreMax, and SodistorePro only
if (installation.Product != (int)ProductType.SodioHome && if (installation.Product != (int)ProductType.SodioHome &&
installation.Product != (int)ProductType.SodiStoreMax) installation.Product != (int)ProductType.SodiStoreMax &&
installation.Product != (int)ProductType.SodistorePro)
return BadRequest("AI diagnostics not available for this product."); return BadRequest("AI diagnostics not available for this product.");
var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription, user.Language ?? "en"); var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription, user.Language ?? "en");
@ -919,8 +924,85 @@ public class Controller : ControllerBase
}); });
} }
// ── Email Preferences ──────────────────────────────────────────────
[HttpGet(nameof(GetEmailPreference))]
public ActionResult GetEmailPreference(Int64 installationId, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null) return Unauthorized();
var pref = Db.GetEmailPreference(installationId);
return Ok(new
{
installationId,
sendWeekly = pref?.SendWeekly ?? false,
sendMonthly = pref?.SendMonthly ?? false,
sendYearly = pref?.SendYearly ?? false
});
}
[HttpPost(nameof(UpdateEmailPreference))]
public ActionResult UpdateEmailPreference(
Int64 installationId, Boolean sendWeekly, Boolean sendMonthly,
Boolean sendYearly, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null) return Unauthorized();
Db.UpsertEmailPreference(new EmailPreference
{
InstallationId = installationId,
SendWeekly = sendWeekly,
SendMonthly = sendMonthly,
SendYearly = sendYearly
});
return Ok();
}
// ── Weekly Performance Report ────────────────────────────────────── // ── Weekly Performance Report ──────────────────────────────────────
private async Task<WeeklyReportResponse?> FetchWeeklyReportAsync(
Int64 installationId, String installationName, String lang,
DateOnly? weekStartDate = null, Boolean forceRegenerate = false)
{
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");
if (!forceRegenerate)
{
var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr);
if (cached != null)
{
var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang);
if (cachedResponse != null)
{
Console.WriteLine($"[WeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}{periodEndStr}, language={lang}");
return cachedResponse;
}
}
}
Console.WriteLine($"[WeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}{periodEndStr}");
var report = await WeeklyReportService.GenerateReportAsync(
installationId, installationName, lang, weekStartDate);
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
return report;
}
/// <summary> /// <summary>
/// Returns a weekly performance report. Serves from cache if available; /// Returns a weekly performance report. Serves from cache if available;
/// generates fresh on first request or when forceRegenerate is true. /// generates fresh on first request or when forceRegenerate is true.
@ -951,43 +1033,9 @@ public class Controller : ControllerBase
{ {
var lang = language ?? user.Language ?? "en"; var lang = language ?? user.Language ?? "en";
// Compute target week dates for cache lookup var report = await FetchWeeklyReportAsync(installationId, installation.Name, lang, weekStartDate, forceRegenerate);
DateOnly periodStart, periodEnd; if (report == null)
if (weekStartDate.HasValue) return BadRequest("Failed to generate report.");
{
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.Name, lang, weekStartDate);
// Persist weekly summary and seed AiInsightCache for this language
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
return Ok(report); return Ok(report);
} }
@ -1094,6 +1142,46 @@ public class Controller : ControllerBase
return Ok(reports); return Ok(reports);
} }
[HttpGet(nameof(GetCurrentMonthPreview))]
public async Task<ActionResult<MonthlyReportSummary>> GetCurrentMonthPreview(
Int64 installationId, Token authToken, String? language = null)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation))
return Unauthorized();
var lang = language ?? user.Language ?? "en";
var preview = await ReportAggregationService.GetCurrentMonthPreviewAsync(installationId, lang);
if (preview == null)
return NotFound("No daily data for the current month.");
return Ok(preview);
}
[HttpGet(nameof(GetCurrentYearPreview))]
public async Task<ActionResult<YearlyReportSummary>> GetCurrentYearPreview(
Int64 installationId, Token authToken, String? language = null)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation))
return Unauthorized();
var lang = language ?? user.Language ?? "en";
var preview = await ReportAggregationService.GetCurrentYearPreviewAsync(installationId, lang);
if (preview == null)
return NotFound("No monthly reports for the current year.");
return Ok(preview);
}
/// <summary> /// <summary>
/// Manually trigger monthly aggregation for an installation. /// Manually trigger monthly aggregation for an installation.
/// Computes monthly report from daily records for the specified year/month. /// Computes monthly report from daily records for the specified year/month.
@ -1474,7 +1562,9 @@ public class Controller : ControllerBase
// ── Report HTML (for PDF download) ───────────────────────────── // ── Report HTML (for PDF download) ─────────────────────────────
[HttpGet(nameof(GetWeeklyReportHtml))] [HttpGet(nameof(GetWeeklyReportHtml))]
public async Task<ActionResult> GetWeeklyReportHtml(Int64 installationId, Token authToken, String? language = null) public async Task<ActionResult> GetWeeklyReportHtml(
Int64 installationId, Token authToken,
String? language = null, String? weekStart = null, String source = "email")
{ {
var user = Db.GetSession(authToken)?.User; var user = Db.GetSession(authToken)?.User;
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
@ -1483,13 +1573,25 @@ public class Controller : ControllerBase
if (installation is null || !user.HasAccessTo(installation)) return Unauthorized(); if (installation is null || !user.HasAccessTo(installation)) return Unauthorized();
var lang = language ?? user.Language ?? "en"; var lang = language ?? user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang);
var html = ReportEmailService.BuildHtmlEmail(report, lang); DateOnly? weekStartDate = null;
if (!String.IsNullOrEmpty(weekStart))
{
if (!DateOnly.TryParseExact(weekStart, "yyyy-MM-dd", out var parsed))
return BadRequest("weekStart must be in yyyy-MM-dd format.");
weekStartDate = parsed;
}
var report = await FetchWeeklyReportAsync(installationId, installation.Name, lang, weekStartDate);
if (report == null)
return BadRequest("Failed to generate report.");
var html = ReportEmailService.BuildHtmlEmail(report, lang, source: source);
return Content(html, "text/html"); return Content(html, "text/html");
} }
[HttpGet(nameof(GetMonthlyReportHtml))] [HttpGet(nameof(GetMonthlyReportHtml))]
public async Task<ActionResult> GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null) public async Task<ActionResult> GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null, String source = "email")
{ {
var user = Db.GetSession(authToken)?.User; var user = Db.GetSession(authToken)?.User;
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
@ -1508,12 +1610,12 @@ public class Controller : ControllerBase
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
$"{report.WeekCount} {s.CountLabel}", s); $"{report.WeekCount} {s.CountLabel}", s, source: source);
return Content(html, "text/html"); return Content(html, "text/html");
} }
[HttpGet(nameof(GetYearlyReportHtml))] [HttpGet(nameof(GetYearlyReportHtml))]
public async Task<ActionResult> GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null) public async Task<ActionResult> GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null, String source = "email")
{ {
var user = Db.GetSession(authToken)?.User; var user = Db.GetSession(authToken)?.User;
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
@ -1532,7 +1634,7 @@ public class Controller : ControllerBase
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
$"{report.MonthCount} {s.CountLabel}", s); $"{report.MonthCount} {s.CountLabel}", s, source: source);
return Content(html, "text/html"); return Content(html, "text/html");
} }
@ -2134,11 +2236,36 @@ public class Controller : ControllerBase
}); });
} }
var assigneeChanged = ticket.AssigneeId != existing.AssigneeId
&& ticket.AssigneeId.HasValue;
if (assigneeChanged)
{
var assignee = Db.GetUserById(ticket.AssigneeId);
Db.Create(new TicketTimelineEvent
{
TicketId = ticket.Id,
EventType = (Int32)TimelineEventType.Assigned,
Description = $"Ticket assigned to {assignee?.Name ?? "unknown"}.",
ActorType = (Int32)TimelineActorType.Human,
ActorId = user.Id,
CreatedAt = DateTime.UtcNow
});
if (assignee is not null)
_ = Task.Run(async () =>
{
try { await assignee.SendTicketAssignedEmail(ticket); }
catch (Exception ex) { Console.WriteLine($"Failed to send ticket assignment email: {ex}"); }
});
}
return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed."); return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed.");
} }
[HttpDelete(nameof(DeleteTicket))] [HttpDelete(nameof(DeleteTicket))]
public ActionResult DeleteTicket(Int64 id, Token authToken) public async Task<ActionResult> DeleteTicket(Int64 id, Token authToken)
{ {
var user = Db.GetSession(authToken)?.User; var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized(); if (user is null || user.UserType != 2) return Unauthorized();
@ -2146,6 +2273,14 @@ public class Controller : ControllerBase
var ticket = Db.GetTicketById(id); var ticket = Db.GetTicketById(id);
if (ticket is null) return NotFound(); if (ticket is null) return NotFound();
// Clean up S3 objects for ticket documents before DB delete
var s3Keys = Db.GetS3KeysForTicketDocuments(id);
if (s3Keys.Count > 0)
{
try { await DocumentBucket.DeleteObjects(s3Keys); }
catch (Exception ex) { Console.WriteLine($"[Documents] S3 cleanup on ticket delete failed: {ex.Message}"); }
}
return Db.Delete(ticket) ? Ok() : StatusCode(500, "Delete failed."); return Db.Delete(ticket) ? Ok() : StatusCode(500, "Delete failed.");
} }
@ -2309,4 +2444,226 @@ public class Controller : ControllerBase
return Ok(); return Ok();
} }
[HttpPut(nameof(AcknowledgeTerms))]
public ActionResult AcknowledgeTerms(Int32 version, Token authToken)
{
var session = Db.GetSession(authToken);
if (session is null) return Unauthorized();
var user = Db.GetUserById(session.User.Id);
if (user is null) return Unauthorized();
user.AcknowledgedTermsVersion = version;
return Db.Update(user) ? Ok() : StatusCode(500);
}
// ── Document Upload/Download ────────────────────────────────────────
private static readonly HashSet<String> AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf", "application/x-pdf"
};
// Some browsers send generic MIME types — allow them if the file extension is valid
private static readonly HashSet<String> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf"
};
private const Int64 MaxFileSizeBytes = 25 * 1024 * 1024; // 25 MB
private static S3Bucket DocumentBucket
{
get
{
var region = new S3Region("https://sos-ch-dk-2.exo.io", ExoCmd.S3Credentials);
return region.Bucket(Program.DocumentBucketName);
}
}
[HttpPost(nameof(UploadDocument))]
[RequestSizeLimit(26_214_400)]
public async Task<ActionResult<Document>> UploadDocument(
IFormFile file,
[FromQuery] Int32 scope,
[FromQuery] Int64? ticketId,
[FromQuery] Int64? ticketCommentId,
[FromQuery] Int64? installationId,
[FromQuery] Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null) return Unauthorized();
if (file.Length == 0)
return BadRequest("File is empty.");
if (file.Length > MaxFileSizeBytes)
return BadRequest($"File exceeds maximum size of {MaxFileSizeBytes / (1024 * 1024)} MB.");
var fileExtension = Path.GetExtension(file.FileName);
if (!AllowedMimeTypes.Contains(file.ContentType) && !AllowedExtensions.Contains(fileExtension))
{
Console.WriteLine($"[Documents] Rejected upload: name={file.FileName}, contentType={file.ContentType}, ext={fileExtension}, size={file.Length}");
return BadRequest($"File type '{file.ContentType}' ({fileExtension}) is not allowed.");
}
Console.WriteLine($"[Documents] Accepting upload: name={file.FileName}, contentType={file.ContentType}, size={file.Length}");
// Validate parent entity exists
var docScope = (DocumentScope)scope;
String s3Prefix;
switch (docScope)
{
case DocumentScope.TicketAttachment:
if (ticketId.HasValue)
{
if (Db.GetTicketById(ticketId.Value) is null) return NotFound("Ticket not found.");
s3Prefix = $"tickets/{ticketId.Value}";
}
else if (ticketCommentId.HasValue)
{
s3Prefix = $"comments/{ticketCommentId.Value}";
}
else
{
return BadRequest("Ticket attachment requires ticketId or ticketCommentId.");
}
break;
case DocumentScope.InstallationDocument:
if (!installationId.HasValue)
return BadRequest("Installation document requires installationId.");
if (Db.GetInstallationById(installationId.Value) is null)
return NotFound("Installation not found.");
s3Prefix = $"installations/{installationId.Value}";
break;
default:
return BadRequest("Invalid scope.");
}
var guid = Guid.NewGuid().ToString("N");
var safeFileName = Path.GetFileName(file.FileName);
var s3Key = $"{s3Prefix}/{guid}/{safeFileName}";
try
{
await using var stream = file.OpenReadStream();
var s3Url = DocumentBucket.Path(s3Key);
var success = await s3Url.PutObject(stream);
if (!success)
return StatusCode(500, "Failed to upload file to storage.");
}
catch (Exception ex)
{
Console.WriteLine($"[Documents] Upload failed: {ex.Message}");
return StatusCode(500, "Failed to upload file to storage.");
}
var document = new Document
{
TicketId = ticketId,
TicketCommentId = ticketCommentId,
InstallationId = installationId,
Scope = scope,
S3Key = s3Key,
OriginalName = safeFileName,
ContentType = file.ContentType,
SizeBytes = file.Length,
UploadedByUserId = user.Id,
CreatedAt = DateTime.UtcNow
};
if (!Db.Create(document))
return StatusCode(500, "Failed to save document metadata.");
return Ok(document);
}
[HttpGet(nameof(DownloadDocument))]
public async Task<ActionResult> DownloadDocument(Int64 id, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null) return Unauthorized();
var document = Db.GetDocumentById(id);
if (document is null) return NotFound("Document not found.");
// Access control: admin can access all; others need installation access
if (user.UserType != 2 && document.InstallationId.HasValue)
{
var inst = Db.GetInstallationById(document.InstallationId.Value);
if (inst is null || !user.HasAccessTo(inst)) return Unauthorized();
}
try
{
var s3Url = DocumentBucket.Path(document.S3Key);
var data = await s3Url.GetObject();
return File(data.ToArray(), document.ContentType, document.OriginalName);
}
catch (Exception ex)
{
Console.WriteLine($"[Documents] Download failed for {document.S3Key}: {ex.Message}");
return StatusCode(500, "Failed to download file from storage.");
}
}
[HttpGet(nameof(GetDocuments))]
public ActionResult<IEnumerable<Document>> GetDocuments(
[FromQuery] Int64? ticketId,
[FromQuery] Int64? ticketCommentId,
[FromQuery] Int64? installationId,
[FromQuery] Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null) return Unauthorized();
if (ticketId.HasValue)
return Ok(Db.GetDocumentsForTicket(ticketId.Value));
if (ticketCommentId.HasValue)
return Ok(Db.GetDocumentsForComment(ticketCommentId.Value));
if (installationId.HasValue)
{
// Access control: admin can list all; others need installation access
if (user.UserType != 2)
{
var inst = Db.GetInstallationById(installationId.Value);
if (inst is null || !user.HasAccessTo(inst)) return Unauthorized();
}
return Ok(Db.GetDocumentsForInstallation(installationId.Value));
}
return BadRequest("Provide ticketId, ticketCommentId, or installationId.");
}
[HttpDelete(nameof(DeleteDocument))]
public async Task<ActionResult> DeleteDocument(Int64 id, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var document = Db.GetDocumentById(id);
if (document is null) return NotFound("Document not found.");
try
{
await DocumentBucket.DeleteObjects(new[] { document.S3Key });
}
catch (Exception ex)
{
Console.WriteLine($"[Documents] S3 delete failed for {document.S3Key}: {ex.Message}");
}
if (!Db.Delete(document))
return StatusCode(500, "Failed to delete document metadata.");
return Ok();
}
} }

View File

@ -0,0 +1,26 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
public enum DocumentScope
{
TicketAttachment = 0,
InstallationDocument = 1
}
public class Document
{
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
[Indexed] public Int64? TicketId { get; set; }
[Indexed] public Int64? TicketCommentId { get; set; }
[Indexed] public Int64? InstallationId { get; set; }
public Int32 Scope { get; set; } = (Int32)DocumentScope.TicketAttachment;
public String S3Key { get; set; } = "";
public String OriginalName { get; set; } = "";
public String ContentType { get; set; } = "";
public Int64 SizeBytes { get; set; }
public Int64 UploadedByUserId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,12 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
public class EmailPreference
{
[PrimaryKey]
public Int64 InstallationId { get; set; }
public Boolean SendWeekly { get; set; }
public Boolean SendMonthly { get; set; }
public Boolean SendYearly { get; set; }
}

View File

@ -8,7 +8,8 @@ public enum ProductType
Salidomo = 1, Salidomo = 1,
SodioHome =2, SodioHome =2,
SodiStoreMax=3, SodiStoreMax=3,
SodistoreGrid=4 SodistoreGrid=4,
SodistorePro=5
} }
public enum StatusType public enum StatusType
@ -27,6 +28,13 @@ public class Installation : TreeNode
public String Location { get; set; } = ""; public String Location { get; set; } = "";
public String Region { get; set; } = ""; public String Region { get; set; } = "";
public String Country { get; set; } = ""; public String Country { get; set; } = "";
public String Street { get; set; } = "";
public String PostCode { get; set; } = "";
public String City { get; set; } = "";
public String Canton { get; set; } = "";
public String DistributionPartner { get; set; } = "";
public String InverterFirmwareVersion { get; set; } = "";
public String BatteryFirmwareVersion { get; set; } = "";
public String VpnIp { get; set; } = ""; public String VpnIp { get; set; } = "";
public String InstallationName { get; set; } = ""; public String InstallationName { get; set; } = "";
@ -52,10 +60,12 @@ public class Installation : TreeNode
public string PvStringsPerInverter { get; set; } = ""; public string PvStringsPerInverter { get; set; } = "";
public string InstallationModel { get; set; } = ""; public string InstallationModel { get; set; } = "";
public string ExternalEms { get; set; } = "No"; public string ExternalEms { get; set; } = "No";
public string CouplingType { get; set; } = "DC";
[Ignore] [Ignore]
public String OrderNumbers { get; set; } public String OrderNumbers { get; set; }
public String VrmLink { get; set; } = ""; public String VrmLink { get; set; } = "";
public string Configuration { get; set; } = ""; public string Configuration { get; set; } = "";
public string NetworkProvider { get; set; } = ""; public string NetworkProvider { get; set; } = "";
public string Email { get; set; } = "";
} }

View File

@ -146,6 +146,7 @@ public static class ExoCmd
String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name: String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name:
installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name: installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name:
installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name: installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name:
installation.Product==(int)ProductType.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + installation.Name:
Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name; Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name;
@ -350,6 +351,7 @@ public static class ExoCmd
String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name: String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name:
installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name: installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name:
installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name: installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name:
installation.Product==(int)ProductType.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + installation.Name:
Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name; Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name;
var contentString = $$""" var contentString = $$"""

View File

@ -11,6 +11,7 @@ public static class InstallationMethods
private static readonly String SalidomoBucketNameSalt = "c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"; private static readonly String SalidomoBucketNameSalt = "c0436b6a-d276-4cd8-9c44-1eae86cf5d0e";
private static readonly String SodioHomeBucketNameSalt = "e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa"; private static readonly String SodioHomeBucketNameSalt = "e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa";
private static readonly String SodistoreGridBucketNameSalt = "5109c126-e141-43ab-8658-f3c44c838ae8"; private static readonly String SodistoreGridBucketNameSalt = "5109c126-e141-43ab-8658-f3c44c838ae8";
private static readonly String SodistoreProBucketNameSalt = "325c9373-9025-4a8d-bf5a-f9eedf1f155c";
public static String BucketName(this Installation installation) public static String BucketName(this Installation installation)
{ {
@ -29,6 +30,11 @@ public static class InstallationMethods
return $"{installation.S3BucketId}-{SodistoreGridBucketNameSalt}"; return $"{installation.S3BucketId}-{SodistoreGridBucketNameSalt}";
} }
if (installation.Product == (int)ProductType.SodistorePro)
{
return $"{installation.S3BucketId}-{SodistoreProBucketNameSalt}";
}
return $"{installation.S3BucketId}-{SalidomoBucketNameSalt}"; return $"{installation.S3BucketId}-{SalidomoBucketNameSalt}";
} }

View File

@ -239,7 +239,7 @@ public static class SessionMethods
} }
if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome || installation.Product == (int)ProductType.SodistoreGrid) if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome || installation.Product == (int)ProductType.SodistoreGrid || installation.Product == (int)ProductType.SodistorePro)
{ {
return user is not null return user is not null
&& user.UserType != 0 && user.UserType != 0
@ -295,7 +295,7 @@ public static class SessionMethods
.Apply(Db.Update); .Apply(Db.Update);
} }
if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodistoreGrid) if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodistoreGrid || installation.Product == (int)ProductType.SodistorePro)
{ {
return user is not null return user is not null

View File

@ -243,22 +243,22 @@ public static class UserMethods
var (subject, body) = (user.Language ?? "en") switch var (subject, body) = (user.Language ?? "en") switch
{ {
"de" => ( "de" => (
"Passwort Ihres Inesco Energy Kontos zurücksetzen", "Passwort Ihres inesco energy Kontos zurücksetzen",
$"Sehr geehrte/r {user.Name}\n" + $"Sehr geehrte/r {user.Name}\n" +
$"Um Ihr Passwort zurückzusetzen, öffnen Sie bitte diesen Link: {resetLink}?token={encodedToken}" $"Um Ihr Passwort zurückzusetzen, öffnen Sie bitte diesen Link: {resetLink}?token={encodedToken}"
), ),
"fr" => ( "fr" => (
"Réinitialisation du mot de passe de votre compte Inesco Energy", "Réinitialisation du mot de passe de votre compte inesco energy",
$"Cher/Chère {user.Name}\n" + $"Cher/Chère {user.Name}\n" +
$"Pour réinitialiser votre mot de passe, veuillez ouvrir ce lien : {resetLink}?token={encodedToken}" $"Pour réinitialiser votre mot de passe, veuillez ouvrir ce lien : {resetLink}?token={encodedToken}"
), ),
"it" => ( "it" => (
"Reimposta la password del tuo account Inesco Energy", "Reimposta la password del tuo account inesco energy",
$"Gentile {user.Name}\n" + $"Gentile {user.Name}\n" +
$"Per reimpostare la password, apra questo link: {resetLink}?token={encodedToken}" $"Per reimpostare la password, apra questo link: {resetLink}?token={encodedToken}"
), ),
_ => ( _ => (
"Reset the password of your Inesco Energy Account", "Reset the password of your inesco energy Account",
$"Dear {user.Name}\n" + $"Dear {user.Name}\n" +
$"To reset your password please open this link: {resetLink}?token={encodedToken}" $"To reset your password please open this link: {resetLink}?token={encodedToken}"
) )
@ -274,24 +274,85 @@ public static class UserMethods
var (subject, body) = (user.Language ?? "en") switch var (subject, body) = (user.Language ?? "en") switch
{ {
"de" => ( "de" => (
"Ihr neues Inesco Energy Konto", "Ihr neues inesco energy Konto",
$"Sehr geehrte/r {user.Name}\n" + $"Sehr geehrte/r {user.Name}\n" +
$"Um Ihr Passwort festzulegen und sich bei Ihrem Inesco Energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}" $"Um Ihr Passwort festzulegen und sich bei Ihrem inesco energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}"
), ),
"fr" => ( "fr" => (
"Votre nouveau compte Inesco Energy", "Votre nouveau compte inesco energy",
$"Cher/Chère {user.Name}\n" + $"Cher/Chère {user.Name}\n" +
$"Pour définir votre mot de passe et vous connecter à votre compte Inesco Energy, veuillez ouvrir ce lien : {resetLink}" $"Pour définir votre mot de passe et vous connecter à votre compte inesco energy, veuillez ouvrir ce lien : {resetLink}"
), ),
"it" => ( "it" => (
"Il tuo nuovo account Inesco Energy", "Il tuo nuovo account inesco energy",
$"Gentile {user.Name}\n" + $"Gentile {user.Name}\n" +
$"Per impostare la password e accedere al suo account Inesco Energy, apra questo link: {resetLink}" $"Per impostare la password e accedere al suo account inesco energy, apra questo link: {resetLink}"
), ),
_ => ( _ => (
"Your new Inesco Energy Account", "Your new inesco energy Account",
$"Dear {user.Name}\n" + $"Dear {user.Name}\n" +
$"To set your password and log in to your Inesco Energy Account open this link: {resetLink}" $"To set your password and log in to your inesco energy Account open this link: {resetLink}"
)
};
return user.SendEmail(subject, body);
}
public static Task SendTicketAssignedEmail(this User user, Ticket ticket)
{
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
var priority = (TicketPriority)ticket.Priority;
var category = (TicketCategory)ticket.Category;
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
$"inesco energy Ticket #{ticket.Id} wurde Ihnen zugewiesen",
$"Sehr geehrte/r {user.Name},\n\n" +
$"Ein Ticket wurde Ihnen zugewiesen:\n\n" +
$"Ticket: #{ticket.Id}\n" +
$"Betreff: {ticket.Subject}\n" +
$"Priorität: {priority}\n" +
$"Kategorie: {category}\n\n" +
$"Beschreibung:\n{ticket.Description}\n\n" +
$"Öffnen Sie das Ticket hier: {ticketLink}\n\n" +
"Mit freundlichen Grüssen\ninesco energy Monitor"
),
"fr" => (
$"inesco energy Le ticket #{ticket.Id} vous a été attribué",
$"Cher/Chère {user.Name},\n\n" +
$"Un ticket vous a été attribué :\n\n" +
$"Ticket : #{ticket.Id}\n" +
$"Objet : {ticket.Subject}\n" +
$"Priorité : {priority}\n" +
$"Catégorie : {category}\n\n" +
$"Description :\n{ticket.Description}\n\n" +
$"Ouvrir le ticket : {ticketLink}\n\n" +
"Cordialement,\ninesco energy Monitor"
),
"it" => (
$"inesco energy Il ticket #{ticket.Id} le è stato assegnato",
$"Gentile {user.Name},\n\n" +
$"Le è stato assegnato un ticket:\n\n" +
$"Ticket: #{ticket.Id}\n" +
$"Oggetto: {ticket.Subject}\n" +
$"Priorità: {priority}\n" +
$"Categoria: {category}\n\n" +
$"Descrizione:\n{ticket.Description}\n\n" +
$"Aprire il ticket: {ticketLink}\n\n" +
"Cordiali saluti,\ninesco energy Monitor"
),
_ => (
$"inesco energy Ticket #{ticket.Id} has been assigned to you",
$"Dear {user.Name},\n\n" +
$"A ticket has been assigned to you:\n\n" +
$"Ticket: #{ticket.Id}\n" +
$"Subject: {ticket.Subject}\n" +
$"Priority: {priority}\n" +
$"Category: {category}\n\n" +
$"Description:\n{ticket.Description}\n\n" +
$"Open the ticket: {ticketLink}\n\n" +
"Best regards,\ninesco energy Monitor"
) )
}; };

View File

@ -94,6 +94,11 @@ public class MonthlyReportSummary
public Int32 WeekCount { get; set; } public Int32 WeekCount { get; set; }
public String AiInsight { get; set; } = ""; public String AiInsight { get; set; } = "";
public String CreatedAt { get; set; } = ""; public String CreatedAt { get; set; } = "";
// Preview-only fields (not stored in DB)
[Ignore] public Boolean IsPreview { get; set; }
[Ignore] public Int32 DaysAvailable { get; set; }
[Ignore] public Int32 DaysInMonth { get; set; }
} }
/// <summary> /// <summary>
@ -137,6 +142,9 @@ public class YearlyReportSummary
public Int32 MonthCount { get; set; } public Int32 MonthCount { get; set; }
public String AiInsight { get; set; } = ""; public String AiInsight { get; set; } = "";
public String CreatedAt { get; set; } = ""; public String CreatedAt { get; set; } = "";
// Preview-only fields (not stored in DB)
[Ignore] public Boolean IsPreview { get; set; }
} }
// ── DTOs for pending aggregation queries (not stored in DB) ── // ── DTOs for pending aggregation queries (not stored in DB) ──

View File

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

View File

@ -75,12 +75,22 @@ public static partial class Db
public static Boolean Create(HourlyEnergyRecord record) => Insert(record); public static Boolean Create(HourlyEnergyRecord record) => Insert(record);
public static Boolean Create(AiInsightCache cache) => Insert(cache); public static Boolean Create(AiInsightCache cache) => Insert(cache);
public static Boolean UpsertEmailPreference(EmailPreference pref)
{
var success = Connection.InsertOrReplace(pref) > 0;
if (success) Backup();
return success;
}
// Ticket system // Ticket system
public static Boolean Create(Ticket ticket) => Insert(ticket); public static Boolean Create(Ticket ticket) => Insert(ticket);
public static Boolean Create(TicketComment comment) => Insert(comment); public static Boolean Create(TicketComment comment) => Insert(comment);
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis); public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev); public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
// Document storage
public static Boolean Create(Document document) => Insert(document);
public static void HandleAction(UserAction newAction) public static void HandleAction(UserAction newAction)
{ {
//Find the total number of actions for this installation //Find the total number of actions for this installation

View File

@ -31,6 +31,7 @@ public static partial class Db
public static TableQuery<DailyEnergyRecord> DailyRecords => Connection.Table<DailyEnergyRecord>(); public static TableQuery<DailyEnergyRecord> DailyRecords => Connection.Table<DailyEnergyRecord>();
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>(); public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>(); public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
public static TableQuery<EmailPreference> EmailPreferences => Connection.Table<EmailPreference>();
// Ticket system tables // Ticket system tables
public static TableQuery<Ticket> Tickets => Connection.Table<Ticket>(); public static TableQuery<Ticket> Tickets => Connection.Table<Ticket>();
@ -38,6 +39,9 @@ public static partial class Db
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>(); public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>(); public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
// Document storage
public static TableQuery<Document> Documents => Connection.Table<Document>();
public static void Init() public static void Init()
{ {
@ -69,12 +73,16 @@ public static partial class Db
Connection.CreateTable<DailyEnergyRecord>(); Connection.CreateTable<DailyEnergyRecord>();
Connection.CreateTable<HourlyEnergyRecord>(); Connection.CreateTable<HourlyEnergyRecord>();
Connection.CreateTable<AiInsightCache>(); Connection.CreateTable<AiInsightCache>();
Connection.CreateTable<EmailPreference>();
// Ticket system tables // Ticket system tables
Connection.CreateTable<Ticket>(); Connection.CreateTable<Ticket>();
Connection.CreateTable<TicketComment>(); Connection.CreateTable<TicketComment>();
Connection.CreateTable<TicketAiDiagnosis>(); Connection.CreateTable<TicketAiDiagnosis>();
Connection.CreateTable<TicketTimelineEvent>(); Connection.CreateTable<TicketTimelineEvent>();
// Document storage
Connection.CreateTable<Document>();
}); });
// One-time migration: normalize legacy long-form language values to ISO codes // One-time migration: normalize legacy long-form language values to ISO codes
@ -83,10 +91,12 @@ public static partial class Db
Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'"); Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'");
Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'"); Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'");
// One-time migration: rebrand to inesco Energy // One-time migration: rebrand to inesco energy
Connection.Execute("UPDATE Folder SET Name = 'inesco Energy' WHERE Name = 'InnovEnergy'"); Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'");
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'");
Connection.Execute("UPDATE Folder SET Name = 'Sodistore Max Installations' WHERE Name = 'SodistoreMax Installations'"); Connection.Execute("UPDATE Folder SET Name = 'Sodistore Max Installations' WHERE Name = 'SodistoreMax Installations'");
Connection.Execute("UPDATE User SET Name = 'inesco Energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'"); Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'");
Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'inesco Energy Master Admin'");
//UpdateKeys(); //UpdateKeys();
CleanupSessions().SupressAwaitWarning(); CleanupSessions().SupressAwaitWarning();
@ -123,6 +133,7 @@ public static partial class Db
fileConnection.CreateTable<DailyEnergyRecord>(); fileConnection.CreateTable<DailyEnergyRecord>();
fileConnection.CreateTable<HourlyEnergyRecord>(); fileConnection.CreateTable<HourlyEnergyRecord>();
fileConnection.CreateTable<AiInsightCache>(); fileConnection.CreateTable<AiInsightCache>();
fileConnection.CreateTable<EmailPreference>();
// Ticket system tables // Ticket system tables
fileConnection.CreateTable<Ticket>(); fileConnection.CreateTable<Ticket>();
@ -130,6 +141,9 @@ public static partial class Db
fileConnection.CreateTable<TicketAiDiagnosis>(); fileConnection.CreateTable<TicketAiDiagnosis>();
fileConnection.CreateTable<TicketTimelineEvent>(); fileConnection.CreateTable<TicketTimelineEvent>();
// Document storage
fileConnection.CreateTable<Document>();
// Migrate new columns: set defaults for existing rows where NULL or empty // Migrate new columns: set defaults for existing rows where NULL or empty
fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''"); fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''");
fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL"); fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL");

View File

@ -129,12 +129,22 @@ public static partial class Db
.Select(t => t.Id).ToList(); .Select(t => t.Id).ToList();
foreach (var tid in ticketIds) foreach (var tid in ticketIds)
{ {
// Delete documents attached to ticket comments
var tCommentIds = TicketComments.Where(c => c.TicketId == tid).Select(c => c.Id).ToList();
foreach (var cid in tCommentIds)
Documents.Delete(d => d.TicketCommentId == cid);
// Delete documents attached directly to the ticket
Documents .Delete(d => d.TicketId == tid);
TicketComments .Delete(c => c.TicketId == tid); TicketComments .Delete(c => c.TicketId == tid);
TicketAiDiagnoses .Delete(d => d.TicketId == tid); TicketAiDiagnoses .Delete(d => d.TicketId == tid);
TicketTimelineEvents.Delete(e => e.TicketId == tid); TicketTimelineEvents.Delete(e => e.TicketId == tid);
} }
Tickets.Delete(t => t.InstallationId == installation.Id); Tickets.Delete(t => t.InstallationId == installation.Id);
// Clean up installation-level documents
Documents.Delete(d => d.InstallationId == installation.Id);
return Installations.Delete(i => i.Id == installation.Id) > 0; return Installations.Delete(i => i.Id == installation.Id) > 0;
} }
} }
@ -218,6 +228,17 @@ public static partial class Db
Boolean DeleteTicketAndChildren() Boolean DeleteTicketAndChildren()
{ {
// Delete documents attached to comments on this ticket
var commentIds = TicketComments
.Where(c => c.TicketId == ticket.Id)
.Select(c => c.Id)
.ToList();
foreach (var cid in commentIds)
Documents.Delete(d => d.TicketCommentId == cid);
// Delete documents attached directly to the ticket
Documents .Delete(d => d.TicketId == ticket.Id);
TicketComments .Delete(c => c.TicketId == ticket.Id); TicketComments .Delete(c => c.TicketId == ticket.Id);
TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id); TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id);
TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id); TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id);
@ -225,6 +246,39 @@ public static partial class Db
} }
} }
public static Boolean Delete(Document document)
{
var success = Documents.Delete(d => d.Id == document.Id) > 0;
if (success) Backup();
return success;
}
public static List<String> GetS3KeysForTicketDocuments(Int64 ticketId)
{
// Get documents attached directly to the ticket
var keys = Documents
.Where(d => d.TicketId == ticketId)
.Select(d => d.S3Key)
.ToList();
// Also get documents attached to comments on this ticket
var commentIds = TicketComments
.Where(c => c.TicketId == ticketId)
.Select(c => c.Id)
.ToList();
foreach (var cid in commentIds)
{
var commentKeys = Documents
.Where(d => d.TicketCommentId == cid)
.Select(d => d.S3Key)
.ToList();
keys.AddRange(commentKeys);
}
return keys;
}
/// <summary> /// <summary>
/// Deletes all report records older than 1 year. Called annually on Jan 2 /// 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 /// after yearly reports are created. Uses fetch-then-delete for string-compared

View File

@ -161,6 +161,11 @@ public static partial class Db
&& c.Language == language) && c.Language == language)
?.InsightText; ?.InsightText;
// ── EmailPreference Queries ─────────────────────────────────────────
public static EmailPreference? GetEmailPreference(Int64 installationId)
=> EmailPreferences.FirstOrDefault(p => p.InstallationId == installationId);
// ── Ticket Queries ────────────────────────────────────────────────── // ── Ticket Queries ──────────────────────────────────────────────────
public static Ticket? GetTicketById(Int64 id) public static Ticket? GetTicketById(Int64 id)
@ -205,4 +210,27 @@ public static partial class Db
.Distinct() .Distinct()
.OrderBy(s => s) .OrderBy(s => s)
.ToList(); .ToList();
// ── Document Queries ────────────────────────────────────────────────
public static Document? GetDocumentById(Int64 id)
=> Documents.FirstOrDefault(d => d.Id == id);
public static List<Document> GetDocumentsForTicket(Int64 ticketId)
=> Documents
.Where(d => d.TicketId == ticketId)
.OrderBy(d => d.CreatedAt)
.ToList();
public static List<Document> GetDocumentsForComment(Int64 commentId)
=> Documents
.Where(d => d.TicketCommentId == commentId)
.OrderBy(d => d.CreatedAt)
.ToList();
public static List<Document> GetDocumentsForInstallation(Int64 installationId)
=> Documents
.Where(d => d.InstallationId == installationId && d.Scope == (Int32)DocumentScope.InstallationDocument)
.OrderBy(d => d.CreatedAt)
.ToList();
} }

View File

@ -3,6 +3,6 @@
"SmtpUsername" : "no-reply@inesco.ch", "SmtpUsername" : "no-reply@inesco.ch",
"SmtpPassword" : "1ci4vi%+bfccIp", "SmtpPassword" : "1ci4vi%+bfccIp",
"SmtpPort" : 587, "SmtpPort" : 587,
"SenderName" : "Inesco Energy", "SenderName" : "inesco energy",
"SenderAddress" : "no-reply@inesco.ch" "SenderAddress" : "no-reply@inesco.ch"
} }

View File

@ -8,6 +8,9 @@ using InnovEnergy.App.Backend.DeleteOldData;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.Lib.S3Utils;
using InnovEnergy.Lib.S3Utils.DataTypes;
using InnovEnergy.Lib.Utils; using InnovEnergy.Lib.Utils;
namespace InnovEnergy.App.Backend; namespace InnovEnergy.App.Backend;
@ -26,6 +29,7 @@ public static class Program
Watchdog.NotifyReady(); Watchdog.NotifyReady();
Db.Init(); Db.Init();
LoadEnvFile(); LoadEnvFile();
EnsureDocumentBucketExists().SupressAwaitWarning();
DiagnosticService.Initialize(); DiagnosticService.Initialize();
TicketDiagnosticService.Initialize(); TicketDiagnosticService.Initialize();
NetworkProviderService.Initialize(); NetworkProviderService.Initialize();
@ -122,6 +126,30 @@ public static class Program
} }
} }
public const String DocumentBucketName = "inesco-documents";
private static async Task EnsureDocumentBucketExists()
{
try
{
var region = new S3Region("https://sos-ch-dk-2.exo.io", ExoCmd.S3Credentials);
var buckets = await region.ListAllBuckets();
if (buckets.Buckets.All(b => b.BucketName != DocumentBucketName))
{
await region.PutBucket(DocumentBucketName);
Console.WriteLine($"[Documents] Created S3 bucket: {DocumentBucketName}");
}
else
{
Console.WriteLine($"[Documents] S3 bucket already exists: {DocumentBucketName}");
}
}
catch (Exception ex)
{
Console.WriteLine($"[Documents] Warning: Could not ensure bucket exists: {ex.Message}");
}
}
private static OpenApiInfo OpenApiInfo { get; } = new OpenApiInfo private static OpenApiInfo OpenApiInfo { get; } = new OpenApiInfo
{ {
Title = "Inesco Backend API", Title = "Inesco Backend API",

View File

@ -17,6 +17,7 @@ public class Session : Relation<String, Int64>
public Boolean AccessToSodistoreMax { get; set; } = false; public Boolean AccessToSodistoreMax { get; set; } = false;
public Boolean AccessToSodioHome { get; set; } = false; public Boolean AccessToSodioHome { get; set; } = false;
public Boolean AccessToSodistoreGrid { get; set; } = false; public Boolean AccessToSodistoreGrid { get; set; } = false;
public Boolean AccessToSodistorePro { get; set; } = false;
[Ignore] public Boolean Valid => DateTime.Now - LastSeen <=MaxAge ; [Ignore] public Boolean Valid => DateTime.Now - LastSeen <=MaxAge ;
// Private backing field // Private backing field
@ -51,6 +52,7 @@ public class Session : Relation<String, Int64>
AccessToSodistoreMax = user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count > 0; AccessToSodistoreMax = user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count > 0;
AccessToSodioHome = user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count > 0; AccessToSodioHome = user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count > 0;
AccessToSodistoreGrid = user.AccessibleInstallations(product: (int)ProductType.SodistoreGrid).ToList().Count > 0; AccessToSodistoreGrid = user.AccessibleInstallations(product: (int)ProductType.SodistoreGrid).ToList().Count > 0;
AccessToSodistorePro = user.AccessibleInstallations(product: (int)ProductType.SodistorePro).ToList().Count > 0;
Console.WriteLine("salimax" +user.AccessibleInstallations(product: (int)ProductType.Salimax).ToList().Count); Console.WriteLine("salimax" +user.AccessibleInstallations(product: (int)ProductType.Salimax).ToList().Count);
Console.WriteLine("AccessToSodistoreMax" +user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count); Console.WriteLine("AccessToSodistoreMax" +user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count);

View File

@ -1,3 +1,4 @@
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using InnovEnergy.App.Backend.DataTypes; using InnovEnergy.App.Backend.DataTypes;
@ -115,6 +116,7 @@ public static class AggregatedJsonParser
/// Tries to read an aggregated JSON file from the installation's S3 bucket. /// Tries to read an aggregated JSON file from the installation's S3 bucket.
/// S3 key: DDMMYYYY.json (directly in bucket root). /// S3 key: DDMMYYYY.json (directly in bucket root).
/// Returns file content or null if not found / error. /// Returns file content or null if not found / error.
/// Handles base64-encoded files (SinexcelCommunication uploads base64).
/// </summary> /// </summary>
public static async Task<String?> TryReadFromS3(Installation installation, String isoDate) public static async Task<String?> TryReadFromS3(Installation installation, String isoDate)
{ {
@ -125,7 +127,8 @@ public static class AggregatedJsonParser
var bucket = region.Bucket(installation.BucketName()); var bucket = region.Bucket(installation.BucketName());
var s3Url = bucket.Path(fileName); var s3Url = bucket.Path(fileName);
return await s3Url.GetObjectAsString(); var raw = await s3Url.GetObjectAsString();
return DecodeContent(raw);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -134,6 +137,29 @@ public static class AggregatedJsonParser
} }
} }
/// <summary>
/// Decodes S3 file content. SinexcelCommunication devices upload DDMMYYYY.json
/// as base64-encoded NDJSON. If the content doesn't start with '{' it is
/// assumed to be base64 and decoded accordingly.
/// </summary>
private static String DecodeContent(String raw)
{
var trimmed = raw.Trim();
if (trimmed.StartsWith('{'))
return raw;
try
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(trimmed));
Console.WriteLine("[AggregatedJsonParser] Decoded base64-encoded S3 content");
return decoded;
}
catch
{
return raw;
}
}
// --- JSON DTOs --- // --- JSON DTOs ---
private sealed class HourlyJsonDto private sealed class HourlyJsonDto

View File

@ -1223,7 +1223,7 @@ textarea:focus{outline:none;border-color:#3498db}
<div id="app"></div> <div id="app"></div>
<div class="nav" id="nav"></div> <div class="nav" id="nav"></div>
<div class="thankyou">Vielen Dank für Ihre Zeit Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏</div> <div class="thankyou">Vielen Dank für Ihre Zeit Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏</div>
<div style="text-align:center;padding-bottom:24px;font-size:11px;color:#bbb">inesco Energy Monitor</div> <div style="text-align:center;padding-bottom:24px;font-size:11px;color:#bbb">inesco energy Monitor</div>
<script> <script>
var ALARMS = %%ALARMS_JSON%%; var ALARMS = %%ALARMS_JSON%%;
var SUBMIT_URL = "%%SUBMIT_URL%%"; var SUBMIT_URL = "%%SUBMIT_URL%%";
@ -1473,7 +1473,7 @@ render();
<p style="margin-bottom:0;font-size:13px;color:#555">Vielen Dank für Ihre Zeit Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏</p> <p style="margin-bottom:0;font-size:13px;color:#555">Vielen Dank für Ihre Zeit Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏</p>
</td></tr> </td></tr>
<tr><td style="background:#ecf0f1;padding:12px 28px;text-align:center;font-size:11px;color:#888;border-top:1px solid #ddd"> <tr><td style="background:#ecf0f1;padding:12px 28px;text-align:center;font-size:11px;color:#888;border-top:1px solid #ddd">
inesco Energy Monitor inesco energy Monitor
</td></tr> </td></tr>
</table></td></tr></table></body></html> </table></td></tr></table></body></html>
"""; """;
@ -1545,7 +1545,7 @@ render();
<p>Hallo <strong>{name}</strong>,</p> <p>Hallo <strong>{name}</strong>,</p>
<p style="margin-top:12px">Kurze Erinnerung die heutige Alarmprüfung <strong>(Stapel {batch.BatchNumber})</strong> schließt um <strong>8:00 Uhr morgen früh</strong>. Es dauert nur 10 Minuten!</p> <p style="margin-top:12px">Kurze Erinnerung die heutige Alarmprüfung <strong>(Stapel {batch.BatchNumber})</strong> schließt um <strong>8:00 Uhr morgen früh</strong>. Es dauert nur 10 Minuten!</p>
<p style="margin:20px 0"><a href="{reviewUrl}" style="background:#3498db;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:13px;display:inline-block">Überprüfung abschließen </a></p> <p style="margin:20px 0"><a href="{reviewUrl}" style="background:#3498db;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:13px;display:inline-block">Überprüfung abschließen </a></p>
<p style="font-size:11px;color:#bbb">inesco Energy Monitor</p> <p style="font-size:11px;color:#bbb">inesco energy Monitor</p>
</body></html> </body></html>
"""; """;
await SendEmailAsync(email, subject, html); await SendEmailAsync(email, subject, html);
@ -1645,7 +1645,7 @@ render();
<table style="border-collapse:collapse;width:100%"> <table style="border-collapse:collapse;width:100%">
{beforeAfterRows} {beforeAfterRows}
</table> </table>
<p style="margin-top:18px;font-size:11px;color:#bbb">inesco Energy Monitor</p> <p style="margin-top:18px;font-size:11px;color:#bbb">inesco energy Monitor</p>
</body></html> </body></html>
"""; """;

View File

@ -50,7 +50,7 @@ public static class DailyIngestionService
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations..."); Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
var installations = Db.Installations var installations = Db.Installations
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3) .Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
.ToList(); .ToList();
foreach (var installation in installations) foreach (var installation in installations)

View File

@ -106,7 +106,7 @@ public static class ReportAggregationService
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation..."); Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
var installations = Db.Installations var installations = Db.Installations
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3) .Where(i => (i.Product == (Int32)ProductType.SodioHome || i.Product == (Int32)ProductType.SodistorePro) && i.Device != 3) // Skip Growatt (device=3)
.ToList(); .ToList();
var generated = 0; var generated = 0;
@ -121,6 +121,9 @@ public static class ReportAggregationService
generated++; generated++;
Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.Name})"); Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.Name})");
// Auto-send email if preference is set
await TryAutoSendWeeklyEmail(installation, report);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -364,20 +367,23 @@ public static class ReportAggregationService
var installationName = installation?.Name ?? $"Installation {installationId}"; var installationName = installation?.Name ?? $"Installation {installationId}";
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy"); var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy");
var weatherCity = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
var weatherRegion = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
var aiInsight = await GenerateMonthlyAiInsightAsync( var aiInsight = await GenerateMonthlyAiInsightAsync(
installationName, monthName, days.Count, installationName, monthName, days.Count,
totalPv, totalConsump, totalGridIn, totalGridOut, totalPv, totalConsump, totalGridIn, totalGridOut,
totalBattChg, totalBattDis, energySaved, savingsCHF, totalBattChg, totalBattDis, energySaved, savingsCHF,
selfSufficiency, batteryEff, language, selfSufficiency, batteryEff, language,
installation?.Location, installation?.Country, installation?.Region); weatherCity, installation?.Country, weatherRegion);
var monthlySummary = new MonthlyReportSummary var monthlySummary = new MonthlyReportSummary
{ {
InstallationId = installationId, InstallationId = installationId,
Year = year, Year = year,
Month = month, Month = month,
PeriodStart = first.ToString("yyyy-MM-dd"), PeriodStart = days.Min(d => d.Date), // actual first data day, not calendar month start
PeriodEnd = last.ToString("yyyy-MM-dd"), PeriodEnd = days.Max(d => d.Date), // actual last data day, not calendar month end
TotalPvProduction = totalPv, TotalPvProduction = totalPv,
TotalConsumption = totalConsump, TotalConsumption = totalConsump,
TotalGridImport = totalGridIn, TotalGridImport = totalGridIn,
@ -413,6 +419,9 @@ public static class ReportAggregationService
}); });
Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({days.Count} days, {first}{last})."); Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({days.Count} days, {first}{last}).");
// Auto-send email if preference is set
await TryAutoSendMonthlyEmail(installationId, monthlySummary);
} }
// ── Year-End Aggregation ────────────────────────────────────────── // ── Year-End Aggregation ──────────────────────────────────────────
@ -526,6 +535,82 @@ public static class ReportAggregationService
}); });
Console.WriteLine($"[ReportAggregation] Yearly report created for installation {installationId}, {year} ({monthlies.Count} months aggregated)."); Console.WriteLine($"[ReportAggregation] Yearly report created for installation {installationId}, {year} ({monthlies.Count} months aggregated).");
// Auto-send email if preference is set
await TryAutoSendYearlyEmail(installationId, yearlySummary);
}
// ── Auto-Send Email Helpers ─────────────────────────────────────────
private static async Task TryAutoSendWeeklyEmail(Installation installation, WeeklyReportResponse report)
{
try
{
var pref = Db.GetEmailPreference(installation.Id);
if (pref is not { SendWeekly: true }) return;
var email = installation.Email;
if (String.IsNullOrWhiteSpace(email))
{
Console.WriteLine($"[AutoSend] Weekly: skipping installation {installation.Id} — no email configured.");
return;
}
await ReportEmailService.SendReportEmailAsync(report, email, "en", installation.Name);
Console.WriteLine($"[AutoSend] Weekly email sent to {email} for installation {installation.Id}.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[AutoSend] Weekly email failed for installation {installation.Id}: {ex.Message}");
}
}
private static async Task TryAutoSendMonthlyEmail(Int64 installationId, MonthlyReportSummary report)
{
try
{
var pref = Db.GetEmailPreference(installationId);
if (pref is not { SendMonthly: true }) return;
var installation = Db.GetInstallationById(installationId);
var email = installation?.Email;
if (String.IsNullOrWhiteSpace(email))
{
Console.WriteLine($"[AutoSend] Monthly: skipping installation {installationId} — no email configured.");
return;
}
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation!.Name, email, "en");
Console.WriteLine($"[AutoSend] Monthly email sent to {email} for installation {installationId}.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[AutoSend] Monthly email failed for installation {installationId}: {ex.Message}");
}
}
private static async Task TryAutoSendYearlyEmail(Int64 installationId, YearlyReportSummary report)
{
try
{
var pref = Db.GetEmailPreference(installationId);
if (pref is not { SendYearly: true }) return;
var installation = Db.GetInstallationById(installationId);
var email = installation?.Email;
if (String.IsNullOrWhiteSpace(email))
{
Console.WriteLine($"[AutoSend] Yearly: skipping installation {installationId} — no email configured.");
return;
}
await ReportEmailService.SendYearlyReportEmailAsync(report, installation!.Name, email, "en");
Console.WriteLine($"[AutoSend] Yearly email sent to {email} for installation {installationId}.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[AutoSend] Yearly email failed for installation {installationId}: {ex.Message}");
}
} }
// ── AI Insight Cache ────────────────────────────────────────────── // ── AI Insight Cache ──────────────────────────────────────────────
@ -591,6 +676,8 @@ public static class ReportAggregationService
var installationName = installation?.Name var installationName = installation?.Name
?? $"Installation {report.InstallationId}"; ?? $"Installation {report.InstallationId}";
var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy"); var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy");
var weatherCity = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
var weatherRegion = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
return GetOrGenerateInsightAsync("monthly", report.Id, language, return GetOrGenerateInsightAsync("monthly", report.Id, language,
() => GenerateMonthlyAiInsightAsync( () => GenerateMonthlyAiInsightAsync(
installationName, monthName, report.WeekCount, installationName, monthName, report.WeekCount,
@ -599,7 +686,7 @@ public static class ReportAggregationService
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalBatteryCharged, report.TotalBatteryDischarged,
report.TotalEnergySaved, report.TotalSavingsCHF, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language, report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language,
installation?.Location, installation?.Country, installation?.Region)); weatherCity, installation?.Country, weatherRegion));
} }
/// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary> /// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary>
@ -794,4 +881,132 @@ Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting ma
return "AI insight could not be generated at this time."; return "AI insight could not be generated at this time.";
} }
// ── Current-Period Previews (not saved to DB) ─────────────────────
public static async Task<MonthlyReportSummary?> GetCurrentMonthPreviewAsync(Int64 installationId, String language = "en")
{
var now = DateTime.UtcNow;
var year = now.Year;
var month = now.Month;
var first = new DateOnly(year, month, 1);
var last = first.AddMonths(1).AddDays(-1);
var days = Db.GetDailyRecords(installationId, first, last);
if (days.Count == 0)
return null;
var totalPv = Math.Round(days.Sum(d => d.PvProduction), 1);
var totalConsump = Math.Round(days.Sum(d => d.LoadConsumption), 1);
var totalGridIn = Math.Round(days.Sum(d => d.GridImport), 1);
var totalGridOut = Math.Round(days.Sum(d => d.GridExport), 1);
var totalBattChg = Math.Round(days.Sum(d => d.BatteryCharged), 1);
var totalBattDis = Math.Round(days.Sum(d => d.BatteryDischarged), 1);
var energySaved = Math.Round(totalConsump - totalGridIn, 1);
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0;
var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0;
var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0;
var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0;
var installation = Db.GetInstallationById(installationId);
var installationName = installation?.Name ?? $"Installation {installationId}";
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy");
var weatherCity = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
var weatherRegion = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
var aiInsight = await GenerateMonthlyAiInsightAsync(
installationName, monthName, days.Count,
totalPv, totalConsump, totalGridIn, totalGridOut,
totalBattChg, totalBattDis, energySaved, savingsCHF,
selfSufficiency, batteryEff, language,
weatherCity, installation?.Country, weatherRegion);
var firstDataDay = days.Min(d => d.Date);
var lastDataDay = days.Max(d => d.Date);
return new MonthlyReportSummary
{
InstallationId = installationId,
Year = year,
Month = month,
PeriodStart = firstDataDay,
PeriodEnd = lastDataDay,
TotalPvProduction = totalPv,
TotalConsumption = totalConsump,
TotalGridImport = totalGridIn,
TotalGridExport = totalGridOut,
TotalBatteryCharged = totalBattChg,
TotalBatteryDischarged = totalBattDis,
TotalEnergySaved = energySaved,
TotalSavingsCHF = savingsCHF,
SelfSufficiencyPercent = selfSufficiency,
SelfConsumptionPercent = selfConsumption,
BatteryEfficiencyPercent = batteryEff,
GridDependencyPercent = gridDependency,
WeekCount = days.Count,
AiInsight = aiInsight,
CreatedAt = DateTime.UtcNow.ToString("o"),
IsPreview = true,
DaysAvailable = days.Count,
DaysInMonth = last.Day,
};
}
public static async Task<YearlyReportSummary?> GetCurrentYearPreviewAsync(Int64 installationId, String language = "en")
{
var year = DateTime.UtcNow.Year;
var monthlies = Db.GetMonthlyReportsForYear(installationId, year);
if (monthlies.Count == 0)
return null;
var totalPv = Math.Round(monthlies.Sum(m => m.TotalPvProduction), 1);
var totalConsump = Math.Round(monthlies.Sum(m => m.TotalConsumption), 1);
var totalGridIn = Math.Round(monthlies.Sum(m => m.TotalGridImport), 1);
var totalGridOut = Math.Round(monthlies.Sum(m => m.TotalGridExport), 1);
var totalBattChg = Math.Round(monthlies.Sum(m => m.TotalBatteryCharged), 1);
var totalBattDis = Math.Round(monthlies.Sum(m => m.TotalBatteryDischarged), 1);
var energySaved = Math.Round(totalConsump - totalGridIn, 1);
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0;
var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0;
var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0;
var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0;
var installation = Db.GetInstallationById(installationId);
var installationName = installation?.Name ?? $"Installation {installationId}";
var aiInsight = await GenerateYearlyAiInsightAsync(
installationName, year, monthlies.Count,
totalPv, totalConsump, totalGridIn, totalGridOut,
totalBattChg, totalBattDis, energySaved, savingsCHF,
selfSufficiency, batteryEff, language);
return new YearlyReportSummary
{
InstallationId = installationId,
Year = year,
PeriodStart = monthlies.Min(m => m.PeriodStart),
PeriodEnd = monthlies.Max(m => m.PeriodEnd),
TotalPvProduction = totalPv,
TotalConsumption = totalConsump,
TotalGridImport = totalGridIn,
TotalGridExport = totalGridOut,
TotalBatteryCharged = totalBattChg,
TotalBatteryDischarged = totalBattDis,
TotalEnergySaved = energySaved,
TotalSavingsCHF = savingsCHF,
SelfSufficiencyPercent = selfSufficiency,
SelfConsumptionPercent = selfConsumption,
BatteryEfficiencyPercent = batteryEff,
GridDependencyPercent = gridDependency,
MonthCount = monthlies.Count,
AiInsight = aiInsight,
CreatedAt = DateTime.UtcNow.ToString("o"),
IsPreview = true,
};
}
} }

File diff suppressed because one or more lines are too long

View File

@ -40,6 +40,33 @@ public static class WeatherService
} }
} }
/// <summary>
/// Returns historical weather for a date range, or null on any failure.
/// Uses Open-Meteo's archive API for past weather data.
/// </summary>
public static async Task<List<DailyWeather>?> GetHistoricalAsync(
string? city, string? country, string? region,
DateOnly startDate, DateOnly endDate)
{
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 FetchHistoricalAsync(lat, lon, startDate, endDate);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeatherService] Error fetching historical weather for '{city}': {ex.Message}");
return null;
}
}
/// <summary> /// <summary>
/// Formats a forecast list into a compact text block for AI prompt injection. /// Formats a forecast list into a compact text block for AI prompt injection.
/// </summary> /// </summary>
@ -52,7 +79,22 @@ public static class WeatherService
return $"- {dayName}: {d.TempMin:F0}{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain"; 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); return "WEATHER FORECAST (coming 7 days):\n" + string.Join("\n", lines);
}
/// <summary>
/// Formats historical weather into a compact text block for AI prompt injection.
/// </summary>
public static string FormatHistoricalForPrompt(List<DailyWeather> historical)
{
var lines = historical.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 "ACTUAL WEATHER (during reporting week):\n" + string.Join("\n", lines);
} }
/// <summary> /// <summary>
@ -145,6 +187,44 @@ public static class WeatherService
return forecast; return forecast;
} }
private static async Task<List<DailyWeather>?> FetchHistoricalAsync(double lat, double lon, DateOnly startDate, DateOnly endDate)
{
var url = $"https://archive-api.open-meteo.com/v1/archive"
+ $"?latitude={lat}&longitude={lon}"
+ $"&start_date={startDate:yyyy-MM-dd}&end_date={endDate:yyyy-MM-dd}"
+ "&daily=temperature_2m_max,temperature_2m_min,sunshine_duration,precipitation_sum,weathercode"
+ "&timezone=Europe/Zurich";
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 historical = new List<DailyWeather>();
for (int i = 0; i < dates.Count; i++)
{
historical.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 {historical.Count}-day historical weather ({startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}).");
return historical;
}
private static string WeatherCodeToDescription(int code) => code switch private static string WeatherCodeToDescription(int code) => code switch
{ {
0 => "Clear sky", 0 => "Clear sky",

View File

@ -179,9 +179,9 @@ public static class WeeklyReportService
// 4. Get installation location for weather forecast // 4. Get installation location for weather forecast
var installation = Db.GetInstallationById(installationId); var installation = Db.GetInstallationById(installationId);
var location = installation?.Location; var location = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
var country = installation?.Country; var country = installation?.Country;
var region = installation?.Region; var region = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
Console.WriteLine($"[WeeklyReportService] Installation {installationId}: Location='{location}', Region='{region}', Country='{country}', HourlyRecords={currentHourlyData.Count}"); Console.WriteLine($"[WeeklyReportService] Installation {installationId}: Location='{location}', Region='{region}', Country='{country}', HourlyRecords={currentHourlyData.Count}");
return await GenerateReportFromDataAsync( return await GenerateReportFromDataAsync(
@ -274,7 +274,8 @@ public static class WeeklyReportService
var aiInsight = await GetAiInsightAsync( var aiInsight = await GetAiInsightAsync(
currentWeekDays, currentSummary, previousSummary, currentWeekDays, currentSummary, previousSummary,
selfSufficiency, totalEnergySaved, totalSavingsCHF, selfSufficiency, totalEnergySaved, totalSavingsCHF,
behavior, installationName, language, location, country, region); behavior, installationName, language,
weekStart, weekEnd, location, country, region);
// Compute data availability — which days of the week are missing // Compute data availability — which days of the week are missing
var availableDates = currentWeekDays.Select(d => d.Date).ToHashSet(); var availableDates = currentWeekDays.Select(d => d.Date).ToHashSet();
@ -356,6 +357,8 @@ public static class WeeklyReportService
BehavioralPattern behavior, BehavioralPattern behavior,
string installationName, string installationName,
string language = "en", string language = "en",
DateOnly? periodStart = null,
DateOnly? periodEnd = null,
string? location = null, string? location = null,
string? country = null, string? country = null,
string? region = null) string? region = null)
@ -367,7 +370,23 @@ public static class WeeklyReportService
return "AI insight unavailable (API key not configured)."; return "AI insight unavailable (API key not configured).";
} }
// Fetch weather forecast for the installation's location // Date labels for prompt clarity
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var periodLabel = periodStart.HasValue && periodEnd.HasValue
? $"{periodStart.Value:MMM dd}{periodEnd.Value:MMM dd}"
: "the reporting week";
// Fetch historical weather for the report week (actual conditions)
List<WeatherService.DailyWeather>? historical = null;
var historicalBlock = "";
if (periodStart.HasValue && periodEnd.HasValue)
{
historical = await WeatherService.GetHistoricalAsync(location, country, region, periodStart.Value, periodEnd.Value);
historicalBlock = historical != null ? "\n" + WeatherService.FormatHistoricalForPrompt(historical) + "\n" : "";
Console.WriteLine($"[WeeklyReportService] Historical weather: {(historical != null ? $"{historical.Count} days fetched" : "SKIPPED (no location or API error)")}");
}
// Fetch weather forecast for the coming week
var forecast = await WeatherService.GetForecastAsync(location, country, region); var forecast = await WeatherService.GetForecastAsync(location, country, region);
var weatherBlock = forecast != null ? "\n" + WeatherService.FormatForPrompt(forecast) + "\n" : ""; 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)")}"); Console.WriteLine($"[WeeklyReportService] Weather forecast: {(forecast != null ? $"{forecast.Count} days fetched" : "SKIPPED (no location or API error)")}");
@ -399,22 +418,29 @@ public static class WeeklyReportService
var battDepleteLine = hasBattery var battDepleteLine = hasBattery
? (behavior.AvgBatteryDepletedHour >= 0 ? (behavior.AvgBatteryDepletedHour >= 0
? $"Battery typically depletes below 20% during {FormatHourSlot(behavior.AvgBatteryDepletedHour)}." ? $"Battery typically depletes below 20% during {FormatHourSlot(behavior.AvgBatteryDepletedHour)}."
: "Battery stayed above 20% SoC every night this week.") : $"Battery stayed above 20% SoC every night during {periodLabel}.")
: ""; : "";
var weekdayWeekendLine = behavior.WeekendAvgDailyLoad > 0 var weekdayWeekendLine = behavior.WeekendAvgDailyLoad > 0
? $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day. Weekend avg: {behavior.WeekendAvgDailyLoad} kWh/day." ? $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day. Weekend avg: {behavior.WeekendAvgDailyLoad} kWh/day."
: $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day."; : $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day.";
// Look up actual weather for best/worst solar days (if historical data available)
var bestDayWeather = historical?.FirstOrDefault(w => w.Date == bestDay.Date);
var worstDayWeather = historical?.FirstOrDefault(w => w.Date == worstDay.Date);
var bestDayWeatherNote = bestDayWeather != null ? $" (actual weather: {bestDayWeather.Description}, {bestDayWeather.SunshineHours:F1}h sunshine)" : "";
var worstDayWeatherNote = worstDayWeather != null ? $" (actual weather: {worstDayWeather.Description}, {worstDayWeather.SunshineHours:F1}h sunshine)" : "";
// Build conditional fact lines // Build conditional fact lines
var pvDailyFact = hasPv var pvDailyFact = hasPv
? $"- PV: total {current.TotalPvProduction:F1} kWh this week. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh), worst: {worstDayName} ({worstDay.PvProduction:F1} kWh). Solar covered {selfSufficiency}% of consumption." ? $"- PV: total {current.TotalPvProduction:F1} kWh for {periodLabel}. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh){bestDayWeatherNote}, worst: {worstDayName} ({worstDay.PvProduction:F1} kWh){worstDayWeatherNote}. Solar covered {selfSufficiency}% of consumption."
: ""; : "";
var battDailyFact = hasBattery var battDailyFact = hasBattery
? $"- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged)." ? $"- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged)."
: ""; : "";
var gridDailyFact = hasGrid var gridDailyFact = hasGrid
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week." ? $"- Grid import: {current.TotalGridImport:F1} kWh total for {periodLabel}."
: ""; : "";
// Behavioral section — only include when hourly data exists // Behavioral section — only include when hourly data exists
@ -432,7 +458,7 @@ public static class WeeklyReportService
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : ""; var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
behavioralSection = $@" behavioralSection = $@"
BEHAVIORAL PATTERN (from hourly data this week): BEHAVIORAL PATTERN (from hourly data for {periodLabel}):
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour - Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
- {weekdayWeekendLine}{pvBehaviorLines} - {weekdayWeekendLine}{pvBehaviorLines}
{gridBehaviorLine} {gridBehaviorLine}
@ -440,12 +466,15 @@ BEHAVIORAL PATTERN (from hourly data this week):
} }
// Build conditional instructions // 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 instruction1 = $"1. Energy savings: Write 12 sentences. Say that during {periodLabel}, 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 hasHistorical = historical != null && historical.Count > 0;
var instruction2 = hasPv var instruction2 = hasPv
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason." ? hasHistorical
? $"2. Solar performance: Comment on the best and worst solar day during {periodLabel}. Use the ACTUAL WEATHER data provided above to explain why — do NOT guess the weather. Reference the real conditions (sunshine hours, weather description) from the historical weather data."
: $"2. Solar performance: Comment on the best and worst solar day during {periodLabel}. Only state the production numbers — do NOT speculate about weather reasons if no weather data is provided."
: hasGrid : hasGrid
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week." ? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid during {periodLabel}."
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern."; : "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
var instruction3 = hasBattery var instruction3 = hasBattery
@ -455,19 +484,24 @@ BEHAVIORAL PATTERN (from hourly data this week):
// Instruction 4 — adapts based on whether we have behavioral data // Instruction 4 — adapts based on whether we have behavioral data
string instruction4; string instruction4;
if (hasBehavior && hasPv) 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."; instruction4 = $"4. Smart action for the coming 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) 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."; instruction4 = $"4. Smart action for the coming 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 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."; instruction4 = $"4. Smart action for the coming week: Based on the energy patterns from {periodLabel}, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
// Instruction 5 — weather outlook with pattern-based predictions // Instruction 5 — weather outlook with pattern-based predictions
var hasWeather = forecast != null; var hasWeather = forecast != null;
var bulletCount = hasWeather ? 5 : 4; var bulletCount = hasWeather ? 5 : 4;
// Forecast date range label for prompt
var forecastLabel = forecast != null && forecast.Count > 0
? $"{DateTime.Parse(forecast.First().Date):MMM dd}{DateTime.Parse(forecast.Last().Date):MMM dd}"
: "the coming days";
var instruction5 = ""; var instruction5 = "";
if (hasWeather && hasPv) if (hasWeather && hasPv)
{ {
// Compute avg daily PV production this week for reference // Compute avg daily PV production for the reporting week as reference
var avgDailyPv = currentWeek.Count > 0 ? Math.Round(currentWeek.Average(d => d.PvProduction), 1) : 0; var avgDailyPv = currentWeek.Count > 0 ? Math.Round(currentWeek.Average(d => d.PvProduction), 1) : 0;
var bestDayPv = Math.Round(bestDay.PvProduction, 1); var bestDayPv = Math.Round(bestDay.PvProduction, 1);
var worstDayPv = Math.Round(worstDay.PvProduction, 1); var worstDayPv = Math.Round(worstDay.PvProduction, 1);
@ -477,36 +511,39 @@ BEHAVIORAL PATTERN (from hourly data this week):
var cloudyDays = forecast.Where(d => d.SunshineHours < 3).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 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}). "; var patternContext = $"During {periodLabel} the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
if (sunnyDays.Count > 0) if (sunnyDays.Count > 0)
patternContext += $"Next week, sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). "; patternContext += $"In the coming days ({forecastLabel}), sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
if (cloudyDays.Count > 0) if (cloudyDays.Count > 0)
patternContext += $"Cloudy/rainy days ({string.Join(", ", cloudyDays)}) will likely produce below average (~{worstDayPv} kWh/day or less). "; 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."; patternContext += $"Total forecast sunshine for {forecastLabel}: {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."; instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days in the coming days ({forecastLabel}) and estimate production based on the reporting 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) 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."; instruction5 = $@"5. Weather outlook: Summarize the weather for the coming days ({forecastLabel}) 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}"". var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Today is {today:yyyy-MM-dd} ({today:dddd}). This report covers the week of {periodLabel}.
Write {bulletCount} 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. IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure.
CRITICAL: All numbers below are pre-calculated. Use these values as-is do not recalculate, round differently, or change any number. CRITICAL: All numbers below are pre-calculated. Use these values as-is do not recalculate, round differently, or change any number.
CRITICAL: Use explicit date references. Say ""during {periodLabel}"" for the reporting week. Say ""the coming days ({forecastLabel})"" for the forecast period. NEVER use ambiguous terms like ""this week"" or ""next week"".
SYSTEM COMPONENTS: PV={hasPv}, Battery={hasBattery}, Grid={hasGrid} SYSTEM COMPONENTS: PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}
DAILY FACTS: DAILY FACTS (for {periodLabel}):
- Total consumption: {current.TotalConsumption:F1} kWh this week. Self-sufficiency: {selfSufficiency}%. - Total consumption: {current.TotalConsumption:F1} kWh. Self-sufficiency: {selfSufficiency}%.
{pvDailyFact} {pvDailyFact}
{battDailyFact} {battDailyFact}
{gridDailyFact} {gridDailyFact}
{behavioralSection} {behavioralSection}
{historicalBlock}
{weatherBlock} {weatherBlock}
INSTRUCTIONS: INSTRUCTIONS:
{instruction1} {instruction1}

View File

@ -105,6 +105,11 @@ public static class RabbitMqManager
monitorLink = monitorLink =
$"https://monitor.inesco.energy/sodistoregrid_installations/list/installation/{installation.S3BucketId}/batteryview"; $"https://monitor.inesco.energy/sodistoregrid_installations/list/installation/{installation.S3BucketId}/batteryview";
} }
else if (installation.Product == (int)ProductType.SodistorePro)
{
monitorLink =
$"https://monitor.inesco.energy/sodistorepro_installations/list/installation/{installation.S3BucketId}/batteryview";
}
else else
{ {
monitorLink = monitorLink =
@ -131,7 +136,7 @@ public static class RabbitMqManager
Console.WriteLine("Send replace battery email to the support team for installation "+installationId); Console.WriteLine("Send replace battery email to the support team for installation "+installationId);
string recipient = "support@innov.energy"; string recipient = "support@innov.energy";
string subject = $"Battery Alarm from {installation.Name}: 2 or more strings broken"; string subject = $"Battery Alarm from {installation.Name}: 2 or more strings broken";
string text = $"Dear inesco Energy Support Team,\n" + string text = $"Dear inesco energy Support Team,\n" +
$"\n"+ $"\n"+
$"Installation Name: {installation.Name}\n"+ $"Installation Name: {installation.Name}\n"+
$"\n"+ $"\n"+
@ -143,7 +148,7 @@ public static class RabbitMqManager
$"\n"+ $"\n"+
$"Thank you for your great support:)"; $"Thank you for your great support:)";
//Disable this function now //Disable this function now
//Mailer.Send("inesco Energy Support Team", recipient, subject, text); //Mailer.Send("inesco energy Support Team", recipient, subject, text);
} }
//Create a new error and add it to the database //Create a new error and add it to the database
Db.HandleError(newError, installationId); Db.HandleError(newError, installationId);

View File

@ -31,7 +31,8 @@ public static class WebsocketManager
(installationConnection.Value.Product == (int)ProductType.Salidomo && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(60)) || (installationConnection.Value.Product == (int)ProductType.Salidomo && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(60)) ||
(installationConnection.Value.Product == (int)ProductType.SodioHome && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(4)) || (installationConnection.Value.Product == (int)ProductType.SodioHome && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(4)) ||
(installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) || (installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) ||
(installationConnection.Value.Product == (int)ProductType.SodistoreGrid && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) (installationConnection.Value.Product == (int)ProductType.SodistoreGrid && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) ||
(installationConnection.Value.Product == (int)ProductType.SodistorePro && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(4))
) )
{ {
Console.WriteLine("Installation ID is " + installationConnection.Key); Console.WriteLine("Installation ID is " + installationConnection.Key);

View File

@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using InnovEnergy.App.SinexcelCommunication.ESS;
namespace InnovEnergy.App.SinexcelCommunication.AggregationService;
public class HourlyAccumulator
{
public DateTime HourStart { get; set; }
public double StartSelfGeneratedElectricity { get; set; }
public double StartElectricityPurchased { get; set; }
public double StartElectricityFed { get; set; }
public double StartBatteryChargeEnergy { get; set; }
public double StartBatteryDischargeEnergy { get; set; }
public double StartLoadPowerConsumption { get; set; }
public double LastSelfGeneratedElectricity { get; set; }
public double LastElectricityPurchased { get; set; }
public double LastElectricityFed { get; set; }
public double LastBatteryChargeEnergy { get; set; }
public double LastBatteryDischargeEnergy { get; set; }
public double LastLoadPowerConsumption { get; set; }
}
public static class EnergyAggregation
{
private static HourlyAccumulator? _currentHourAccumulator;
private static DateTime? _lastDailySaveDate;
public static HourlyEnergyData? ProcessHourlyData(StatusRecord statusRecord, DateTime timestamp)
{
var r = statusRecord.InverterRecord;
var hourStart = new DateTime(timestamp.Year, timestamp.Month, timestamp.Day, timestamp.Hour, 0, 0);
// First call
if (_currentHourAccumulator == null)
{
_currentHourAccumulator = new HourlyAccumulator
{
HourStart = hourStart,
StartSelfGeneratedElectricity = r.SelfGeneratedElectricity,
StartElectricityPurchased = r.ElectricityPurchased,
StartElectricityFed = r.ElectricityFed,
StartBatteryChargeEnergy = r.BatteryChargeEnergy,
StartBatteryDischargeEnergy = r.BatteryDischargeEnergy,
StartLoadPowerConsumption = r.LoadPowerConsumption,
LastSelfGeneratedElectricity = r.SelfGeneratedElectricity,
LastElectricityPurchased = r.ElectricityPurchased,
LastElectricityFed = r.ElectricityFed,
LastBatteryChargeEnergy = r.BatteryChargeEnergy,
LastBatteryDischargeEnergy = r.BatteryDischargeEnergy,
LastLoadPowerConsumption = r.LoadPowerConsumption
};
return null;
}
// Still same hour → just update last values
if (_currentHourAccumulator.HourStart == hourStart)
{
_currentHourAccumulator.LastSelfGeneratedElectricity = r.SelfGeneratedElectricity;
_currentHourAccumulator.LastElectricityPurchased = r.ElectricityPurchased;
_currentHourAccumulator.LastElectricityFed = r.ElectricityFed;
_currentHourAccumulator.LastBatteryChargeEnergy = r.BatteryChargeEnergy;
_currentHourAccumulator.LastBatteryDischargeEnergy = r.BatteryDischargeEnergy;
_currentHourAccumulator.LastLoadPowerConsumption = r.LoadPowerConsumption;
return null;
}
// Hour changed → finalize previous hour
var completedHour = new HourlyEnergyData
{
Timestamp = _currentHourAccumulator.HourStart,
SelfGeneratedElectricity = SafeDiff(
_currentHourAccumulator.LastSelfGeneratedElectricity,
_currentHourAccumulator.StartSelfGeneratedElectricity),
ElectricityPurchased = SafeDiff(
_currentHourAccumulator.LastElectricityPurchased,
_currentHourAccumulator.StartElectricityPurchased),
ElectricityFed = SafeDiff(
_currentHourAccumulator.LastElectricityFed,
_currentHourAccumulator.StartElectricityFed),
BatteryChargeEnergy = SafeDiff(
_currentHourAccumulator.LastBatteryChargeEnergy,
_currentHourAccumulator.StartBatteryChargeEnergy),
BatteryDischargeEnergy = SafeDiff(
_currentHourAccumulator.LastBatteryDischargeEnergy,
_currentHourAccumulator.StartBatteryDischargeEnergy),
LoadPowerConsumption = SafeDiff(
_currentHourAccumulator.LastLoadPowerConsumption,
_currentHourAccumulator.StartLoadPowerConsumption)
};
// Start new hour with current sample
_currentHourAccumulator = new HourlyAccumulator
{
HourStart = hourStart,
StartSelfGeneratedElectricity = r.SelfGeneratedElectricity,
StartElectricityPurchased = r.ElectricityPurchased,
StartElectricityFed = r.ElectricityFed,
StartBatteryChargeEnergy = r.BatteryChargeEnergy,
StartBatteryDischargeEnergy = r.BatteryDischargeEnergy,
StartLoadPowerConsumption = r.LoadPowerConsumption,
LastSelfGeneratedElectricity = r.SelfGeneratedElectricity,
LastElectricityPurchased = r.ElectricityPurchased,
LastElectricityFed = r.ElectricityFed,
LastBatteryChargeEnergy = r.BatteryChargeEnergy,
LastBatteryDischargeEnergy = r.BatteryDischargeEnergy,
LastLoadPowerConsumption = r.LoadPowerConsumption
};
return completedHour;
}
public static DailyEnergyData? TryCreateDailyData(StatusRecord statusRecord, DateTime timestamp)
{
if (timestamp is { Hour: 23, Minute: 59 })
{
if (_lastDailySaveDate != timestamp.Date)
{
_lastDailySaveDate = timestamp.Date;
var r = statusRecord.InverterRecord;
return new DailyEnergyData
{
Timestamp = timestamp,
DailySelfGeneratedElectricity = r.DailySelfGeneratedElectricity,
DailyElectricityPurchased = r.DailyElectricityPurchased,
DailyElectricityFed = r.DailyElectricityFed,
BatteryDailyChargeEnergy = r.BatteryDailyChargeEnergy,
BatteryDailyDischargeEnergy = r.BatteryDailyDischargeEnergy,
DailyLoadPowerConsumption = r.DailyLoadPowerConsumption
};
}
}
return null;
}
private static double SafeDiff(double endValue, double startValue)
{
var diff = endValue - startValue;
return diff < 0 ? 0 : diff;
}
}

View File

@ -7,7 +7,7 @@ public class Configuration
public Double MinimumSoC { get; set; } public Double MinimumSoC { get; set; }
public Double MaximumDischargingCurrent { get; set; } public Double MaximumDischargingCurrent { get; set; }
public Double MaximumChargingCurrent { get; set; } public Double MaximumChargingCurrent { get; set; }
public WorkingMode OperatingPriority { get; set; } public OperatingPriority OperatingPriority { get; set; }
public Int16 BatteriesCount { get; set; } public Int16 BatteriesCount { get; set; }
public Int16 ClusterNumber { get; set; } public Int16 ClusterNumber { get; set; }
public Int16 PvNumber { get; set; } public Int16 PvNumber { get; set; }

View File

@ -0,0 +1,9 @@
namespace InnovEnergy.App.SinexcelCommunication.DataTypes;
public enum OperatingPriority
{
ModeNotSynched = -1,
LoadPriority = 0,
BatteryPriority = 1,
GridPriority = 2,
}

View File

@ -0,0 +1,96 @@
using InnovEnergy.App.SinexcelCommunication.DataTypes;
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL;
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType;
using InnovEnergy.Lib.Units;
using InnovEnergy.Lib.Units.Power;
namespace InnovEnergy.App.SinexcelCommunication.ESS;
public class InverterRecords
{
public required Energy SelfGeneratedElectricity { get; init; }
public required Energy ElectricityPurchased { get; init; }
public required Energy ElectricityFed { get; init; }
public required Energy BatteryChargeEnergy { get; init; }
public required Energy BatteryDischargeEnergy { get; init; }
public required Energy LoadPowerConsumption { get; init; }
public required Energy DailySelfGeneratedElectricity { get; init; }
public required Energy DailyElectricityPurchased { get; init; }
public required Energy DailyElectricityFed { get; init; }
public required Energy BatteryDailyChargeEnergy { get; init; }
public required Energy BatteryDailyDischargeEnergy { get; init; }
public required Energy DailyLoadPowerConsumption { get; init; }
// public required ActivePower TotalConsumptionPower { get; init; }
public required ActivePower TotalPhotovoltaicPower { get; init; }
public required ActivePower TotalBatteryPower { get; init; }
public required ActivePower TotalLoadPower { get; init; }
public required ActivePower TotalGridPower { get; init; }
public required OperatingPriority OperatingPriority { get; init; }
public required Voltage AvgBatteryVoltage { get; init; }
public required Current TotalBatteryCurrent { get; init; }
public required Percent AvgBatterySoc { get; init; }
public required Percent AvgBatterySoh { get; init; }
public required Temperature AvgBatteryTemp { get; init; }
public required Percent MinSoc { get; init; }
public required Current MaxChargeCurrent { get; init; }
public required Current MaxDischargingCurrent { get; init; }
public required ActivePower GridPower { get; init; }
public required Frequency GridFrequency { get; init; }
public required ActivePower InverterPower { get; init; }
public required EnablePowerLimitation EnableGridExport { get; init; }
public required ActivePower GridExportPower { get; init; }
public required IReadOnlyList<SinexcelRecord> Devices { get; init; }
public static InverterRecords? FromInverters(IReadOnlyList<SinexcelRecord>? records)
{
if (records is null || records.Count == 0)
return null;
return new InverterRecords
{
Devices = records,
DailySelfGeneratedElectricity = records.Sum(r => r.DailySelfGeneratedElectricity),
DailyElectricityPurchased = records.Sum(r => r.DailyElectricityPurchased),
DailyElectricityFed = records.Sum(r => r.DailyElectricityFed),
BatteryDailyChargeEnergy = records.Sum(r => r.BatteryDailyChargeEnergy),
BatteryDailyDischargeEnergy = records.Sum(r => r.BatteryDailyDischargeEnergy),
DailyLoadPowerConsumption = records.Sum(r => r.DailyLoadPowerConsumption),
SelfGeneratedElectricity = records.Sum(r => r.SelfGeneratedElectricity),
ElectricityPurchased = records.Sum(r => r.TotalEnergyToUser),
ElectricityFed = records.Sum(r => r.TotalEnergyToGrid),
BatteryChargeEnergy = records.Sum(r => r.BatteryCharge),
BatteryDischargeEnergy = records.Sum(r => r.BatteryDischarge),
LoadPowerConsumption = records.Sum(r => r.LoadPowerConsumption),
// TotalConsumptionPower = records.Sum(r => r.ConsumptionPower), // consumption same as load
TotalPhotovoltaicPower = records.Sum(r => r.TotalPhotovoltaicPower),
TotalBatteryPower = records.Sum(r => r.TotalBatteryPower),
TotalLoadPower = records.Sum(r => r.TotalLoadPower),
TotalGridPower = records.Sum(b => b.TotalGridPower),
OperatingPriority = records.Select(r => r.WorkingMode).Distinct().Count() == 1 ? (OperatingPriority)records.First().WorkingMode: OperatingPriority.ModeNotSynched,
AvgBatteryVoltage = records.SelectMany(r => new[] { r.Battery1Voltage.Value, r.Battery2Voltage.Value }).Where(v => v > 0).DefaultIfEmpty(0).Average(),
TotalBatteryCurrent = records.SelectMany(r => new [] { r.Battery1Current.Value, r.Battery2Current.Value}).Sum(),
AvgBatterySoc = records.SelectMany(r => new[] { r.Battery1Soc.Value, r.Battery2Soc.Value }).Average(),
AvgBatterySoh = records.SelectMany(r => new[] { r.Battery1Soh.Value, r.Battery2Soh.Value }).Average(),
MinSoc = records.SelectMany(r => new[] { r.Battery1BackupSoc, r.Battery2BackupSoc}).Min(),
AvgBatteryTemp = records.SelectMany(r => new[] { r.Battery1Temperature.Value, r.Battery2Temperature.Value }).Average(),
MaxChargeCurrent = records.SelectMany(r => new[] { r.Battery1MaxChargingCurrent, r.Battery2MaxChargingCurrent }).Min(),
MaxDischargingCurrent = records.SelectMany(r => new[] { r.Battery1MaxDischargingCurrent, r.Battery2MaxDischargingCurrent }).Max(),
GridPower = records.Sum(r => r.TotalGridPower),
GridFrequency = records.Average(r => r.GridVoltageFrequency),
InverterPower = records.Sum( r => r.InverterActivePower ),
EnableGridExport = records.Select(r => r.EnableGridExport).Distinct().Count() == 1 ? records.First().EnableGridExport: EnablePowerLimitation.Prohibited,
GridExportPower = records.Sum(r => r.PowerGridExportLimit)
};
}
}

View File

@ -5,6 +5,7 @@ namespace InnovEnergy.App.SinexcelCommunication.ESS;
public record StatusRecord public record StatusRecord
{ {
public required SinexcelRecord InverterRecord { get; set; } public required InverterRecords? InverterRecord { get; set; }
public required Config Config { get; set; } public required Config Config { get; set; }
} }

View File

@ -13,20 +13,37 @@ public static class MiddlewareAgent
private static IPAddress? _controllerIpAddress; private static IPAddress? _controllerIpAddress;
private static EndPoint? _endPoint; private static EndPoint? _endPoint;
public static void InitializeCommunicationToMiddleware() public static bool InitializeCommunicationToMiddleware()
{
try
{ {
_controllerIpAddress = FindVpnIp(); _controllerIpAddress = FindVpnIp();
if (Equals(IPAddress.None, _controllerIpAddress)) if (Equals(IPAddress.None, _controllerIpAddress))
{ {
Console.WriteLine("There is no VPN interface, exiting..."); Console.WriteLine("There is no VPN interface.");
_udpListener = null;
return false;
} }
const Int32 udpPort = 9000; const int udpPort = 9000;
_endPoint = new IPEndPoint(_controllerIpAddress, udpPort); _endPoint = new IPEndPoint(_controllerIpAddress, udpPort);
_udpListener?.Close();
_udpListener?.Dispose();
_udpListener = new UdpClient(); _udpListener = new UdpClient();
_udpListener.Client.Blocking = false; _udpListener.Client.Blocking = false;
_udpListener.Client.Bind(_endPoint); _udpListener.Client.Bind(_endPoint);
Console.WriteLine($"UDP listener bound to {_endPoint}");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Failed to initialize middleware communication: {ex}");
_udpListener = null;
return false;
}
} }
private static IPAddress FindVpnIp() private static IPAddress FindVpnIp()
@ -50,42 +67,96 @@ public static class MiddlewareAgent
return IPAddress.None; return IPAddress.None;
} }
public static Configuration? SetConfigurationFile() public static Configuration? SetConfigurationFile()
{ {
if (_udpListener.Available > 0) try
{ {
IPEndPoint? serverEndpoint = null; // Ensure listener is initialized
if (_udpListener == null)
{
Console.WriteLine("UDP listener not initialized, trying to initialize...");
InitializeCommunicationToMiddleware();
var replyMessage = "ACK"; if (_udpListener == null)
var replyData = Encoding.UTF8.GetBytes(replyMessage); {
Console.WriteLine("Failed to initialize UDP listener.");
return null;
}
}
// Check if data is available
if (_udpListener.Available <= 0)
return null;
IPEndPoint? serverEndpoint = null;
var udpMessage = _udpListener.Receive(ref serverEndpoint); var udpMessage = _udpListener.Receive(ref serverEndpoint);
var message = Encoding.UTF8.GetString(udpMessage); var message = Encoding.UTF8.GetString(udpMessage);
Console.WriteLine($"Received raw UDP message from {serverEndpoint}: {message}");
var config = JsonSerializer.Deserialize<Configuration>(message); var config = JsonSerializer.Deserialize<Configuration>(message);
if (config != null) if (config != null)
{ {
Console.WriteLine($"Received a configuration message: " + Console.WriteLine(
"MinimumSoC is " + config.MinimumSoC + " and operating priorty is " +config.OperatingPriority + "Number of batteries is " + config.BatteriesCount $"Received a configuration message:\n" +
+ "Maximum Charging current is "+ config.MaximumChargingCurrent + "/n" + "Maximum Discharging current is " + config.MaximumDischargingCurrent $"MinimumSoC: {config.MinimumSoC}\n" +
+ "StartTimeChargeandDischargeDayandTime is" + config.StartTimeChargeandDischargeDayandTime + "StopTimeChargeandDischargeDayandTime is" + config.StopTimeChargeandDischargeDayandTime $"OperatingPriority: {config.OperatingPriority}\n" +
+ "TimeChargeandDischargePowert is " + config.TimeChargeandDischargePower + " Control permission is" + config.ControlPermission); $"Number of batteries: {config.BatteriesCount}\n" +
$"Maximum Charging current: {config.MaximumChargingCurrent}\n" +
$"Maximum Discharging current: {config.MaximumDischargingCurrent}\n" +
$"StartTimeChargeandDischargeDayandTime: {config.StartTimeChargeandDischargeDayandTime}\n" +
$"StopTimeChargeandDischargeDayandTime: {config.StopTimeChargeandDischargeDayandTime}\n" +
$"TimeChargeandDischargePower: {config.TimeChargeandDischargePower}\n" +
$"ControlPermission: {config.ControlPermission}"
);
// Send ACK
var replyMessage = "ACK";
var replyData = Encoding.UTF8.GetBytes(replyMessage);
// Send the reply to the sender's endpoint
_udpListener.Send(replyData, replyData.Length, serverEndpoint); _udpListener.Send(replyData, replyData.Length, serverEndpoint);
Console.WriteLine($"Replied to {serverEndpoint}: {replyMessage}"); Console.WriteLine($"Replied to {serverEndpoint}: {replyMessage}");
return config; return config;
} }
else
{
Console.WriteLine("Received UDP message but failed to deserialize Configuration.");
return null;
}
}
catch (SocketException ex)
{
Console.WriteLine($"Socket error in SetConfigurationFile: {ex}");
// Recover by reinitializing
try
{
_udpListener?.Close();
_udpListener?.Dispose();
}
catch
{
// ignored
} }
if (_endPoint != null && !_endPoint.Equals(_udpListener.Client.LocalEndPoint as IPEndPoint)) _udpListener = null;
{
Console.WriteLine("UDP address has changed, rebinding...");
InitializeCommunicationToMiddleware(); InitializeCommunicationToMiddleware();
}
return null; return null;
} }
catch (JsonException ex)
{
Console.WriteLine($"JSON deserialization error: {ex}");
return null;
}
catch (Exception ex)
{
Console.WriteLine($"Unexpected error in SetConfigurationFile: {ex}");
return null;
}
}
} }

View File

@ -1,4 +1,5 @@
using System.IO.Compression; using System.Diagnostics;
using System.IO.Compression;
using System.IO.Ports; using System.IO.Ports;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@ -24,6 +25,7 @@ using Formatting = Newtonsoft.Json.Formatting;
using JsonSerializer = System.Text.Json.JsonSerializer; using JsonSerializer = System.Text.Json.JsonSerializer;
using static InnovEnergy.App.SinexcelCommunication.MiddlewareClasses.MiddlewareAgent; using static InnovEnergy.App.SinexcelCommunication.MiddlewareClasses.MiddlewareAgent;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using InnovEnergy.App.SinexcelCommunication.AggregationService;
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType; using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType;
using InnovEnergy.Lib.Protocols.Modbus.Protocol; using InnovEnergy.Lib.Protocols.Modbus.Protocol;
using static InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType.WorkingMode; using static InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType.WorkingMode;
@ -35,12 +37,12 @@ namespace InnovEnergy.App.SinexcelCommunication;
[SuppressMessage("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic access otherwise can break functionality when trimming application code")] [SuppressMessage("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic access otherwise can break functionality when trimming application code")]
internal static class Program internal static class Program
{ {
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(5); private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(10);
private const UInt16 NbrOfFileToConcatenate = 15; // add this to config file private const UInt16 NbrOfFileToConcatenate = 15; // add this to config file
private static UInt16 _fileCounter = 0; private static UInt16 _fileCounter = 0;
private static Channel _sinexcelChannel1; private static List<Channel> _sinexcelChannel;
private static Channel _sinexcelChannel2; private static DateTime? _lastUploadedAggregatedDate;
private static DailyEnergyData? _pendingDailyData;
private static readonly String SwVersionNumber = " V1.00." + DateTime.Today; private static readonly String SwVersionNumber = " V1.00." + DateTime.Today;
private const String VpnServerIp = "10.2.0.11"; private const String VpnServerIp = "10.2.0.11";
private static Boolean _subscribedToQueue = false; private static Boolean _subscribedToQueue = false;
@ -57,19 +59,29 @@ internal static class Program
private const Int32 HeartbeatIntervalSeconds = 60; private const Int32 HeartbeatIntervalSeconds = 60;
// move all this to config file
private const String Port1 = "/dev/ttyUSB0";
private const String Port2 = "/dev/ttyUSB1";
private const Byte SlaveId = 1; private const Byte SlaveId = 1;
private const Parity Parity = 0; //none
private const Int32 StopBits = 1;
private const Int32 BaudRate = 115200;
private const Int32 DataBits = 8;
public static async Task Main(String[] args) public static async Task Main(String[] args)
{ {
_sinexcelChannel1 = new SerialPortChannel(Port1, BaudRate, Parity, DataBits, StopBits); var config = Config.Load();
_sinexcelChannel2 = new SerialPortChannel(Port2, BaudRate, Parity, DataBits, StopBits); var d = config.Devices;
var serial = d.Serial;
Channel CreateChannel(SodiDevice device) => device.DeviceState == DeviceState.Disabled
? new NullChannel()
: new SerialPortChannel(device.Port,serial.BaudRate,serial.Parity,serial.DataBits,serial.StopBits);
_sinexcelChannel = new List<Channel>()
{
CreateChannel(d.Inverter1),
CreateChannel(d.Inverter2),
CreateChannel(d.Inverter3),
CreateChannel(d.Inverter4)
};
InitializeCommunicationToMiddleware(); InitializeCommunicationToMiddleware();
while (true) while (true)
@ -92,20 +104,23 @@ internal static class Program
Console.WriteLine("Starting Sinexcel Communication, SW Version : " + SwVersionNumber); Console.WriteLine("Starting Sinexcel Communication, SW Version : " + SwVersionNumber);
var sinexcelDevice1 = new SinexcelDevice(_sinexcelChannel1, SlaveId); var devices = _sinexcelChannel
var sinexcelDevice2 = new SinexcelDevice(_sinexcelChannel2, SlaveId); .Where(ch => ch is not NullChannel)
.Select(ch => new SinexcelDevice(ch, SlaveId))
.ToList();
StatusRecord? ReadStatus() StatusRecord? ReadStatus()
{ {
var config = Config.Load(); var config = Config.Load();
var sinexcelRecord1 = sinexcelDevice1.Read(); var listOfInverterRecord = devices
var sinexcelRecord2 = sinexcelDevice2.Read(); .Select(device => device.Read())
.ToList();
InverterRecords? inverterRecords = InverterRecords.FromInverters(listOfInverterRecord);
return new StatusRecord return new StatusRecord
{ {
InverterRecord1 = sinexcelRecord1, InverterRecord = inverterRecords,
InverterRecord2 = sinexcelRecord2,
Config = config // load from disk every iteration, so config can be changed while running Config = config // load from disk every iteration, so config can be changed while running
}; };
} }
@ -128,82 +143,23 @@ internal static class Program
try try
{ {
Watchdog.NotifyAlive(); Watchdog.NotifyAlive();
var startTime = DateTime.Now; var startTime = DateTime.Now;
Console.WriteLine("***************************** Reading Battery Data *********************************************"); Console.WriteLine("***************************** Reading Battery Data *********************************************");
Console.WriteLine(startTime.ToString("HH:mm:ss.fff ")+ "Start Reading"); Console.WriteLine(startTime.ToString("HH:mm:ss.fff ")+ "Start Reading");
// the order matter of the next three lines
var statusrecord = ReadStatus(); var statusrecord = ReadStatus();
if (statusrecord == null) if (statusrecord == null)
return null; return null;
_ = CreateAggregatedData(statusrecord);
Console.WriteLine(" ************************************************ Inverter 1 ************************************************ "); var invDevices = statusrecord.InverterRecord?.Devices;
Console.WriteLine( statusrecord.InverterRecord1.SystemDateTime + " SystemDateTime ");
Console.WriteLine( statusrecord.InverterRecord1.TotalPhotovoltaicPower + " TotalPhotovoltaicPower "); if (invDevices != null)
Console.WriteLine( statusrecord.InverterRecord1.TotalBatteryPower + " TotalBatteryPower "); {
Console.WriteLine( statusrecord.InverterRecord1.TotalLoadPower + " TotalLoadPower "); var index = 1;
Console.WriteLine( statusrecord.InverterRecord1.TotalGridPower + " TotalGridPower "); foreach (var inverter in invDevices)
PrintInverterData(inverter, index++);
}
Console.WriteLine( statusrecord.InverterRecord1.Battery1Power + " Battery1Power ");
Console.WriteLine( statusrecord.InverterRecord1.Battery1Soc + " Battery1Soc ");
Console.WriteLine( statusrecord.InverterRecord1.Battery1BackupSoc + " Battery1BackupSoc ");
Console.WriteLine( statusrecord.InverterRecord1.Battery1MinSoc + " Battery1MinSoc ");
Console.WriteLine( statusrecord.InverterRecord1.Battery2Power + " Battery2Power ");
Console.WriteLine( statusrecord.InverterRecord1.Battery2Soc + " Battery2Soc ");
Console.WriteLine( statusrecord.InverterRecord1.Battery2BackupSoc + " Battery2BackupSoc ");
Console.WriteLine( statusrecord.InverterRecord1.Battery2MinSoc + " Battery2MinSoc ");
Console.WriteLine( statusrecord.InverterRecord1.EnableGridExport + " EnableGridExport ");
Console.WriteLine( statusrecord.InverterRecord1.PowerGridExportLimit + " PowerGridExportLimit ");
Console.WriteLine( statusrecord.InverterRecord1.PowerOn + " PowerOn ");
Console.WriteLine( statusrecord.InverterRecord1.PowerOff + " PowerOff ");
Console.WriteLine( statusrecord.InverterRecord1.WorkingMode + " WorkingMode ");
Console.WriteLine( statusrecord.InverterRecord1.GridSwitchMethod + " GridSwitchMethod ");
Console.WriteLine( statusrecord.InverterRecord1.ThreePhaseWireSystem + " ThreePhaseWireSystem ");
Console.WriteLine(" ************************************************ Inverter 2 ************************************************ ");
Console.WriteLine( statusrecord.InverterRecord2.SystemDateTime + " SystemDateTime ");
Console.WriteLine( statusrecord.InverterRecord2.TotalPhotovoltaicPower + " TotalPhotovoltaicPower ");
Console.WriteLine( statusrecord.InverterRecord2.TotalBatteryPower + " TotalBatteryPower ");
Console.WriteLine( statusrecord.InverterRecord2.TotalLoadPower + " TotalLoadPower ");
Console.WriteLine( statusrecord.InverterRecord2.TotalGridPower + " TotalGridPower ");
Console.WriteLine( statusrecord.InverterRecord2.Battery1Power + " Battery1Power ");
Console.WriteLine( statusrecord.InverterRecord2.Battery1Soc + " Battery1Soc ");
Console.WriteLine( statusrecord.InverterRecord2.Battery1BackupSoc + " Battery1BackupSoc ");
Console.WriteLine( statusrecord.InverterRecord2.Battery1MinSoc + " Battery1MinSoc ");
Console.WriteLine( statusrecord.InverterRecord2.Battery2Power + " Battery2Power ");
Console.WriteLine( statusrecord.InverterRecord2.Battery2Soc + " Battery2Soc ");
Console.WriteLine( statusrecord.InverterRecord2.Battery2BackupSoc + " Battery2BackupSoc ");
Console.WriteLine( statusrecord.InverterRecord2.Battery2MinSoc + " Battery2MinSoc ");
Console.WriteLine( statusrecord.InverterRecord2.EnableGridExport + " EnableGridExport ");
Console.WriteLine( statusrecord.InverterRecord2.PowerGridExportLimit + " PowerGridExportLimit ");
Console.WriteLine( statusrecord.InverterRecord2.PowerOn + " PowerOn ");
Console.WriteLine( statusrecord.InverterRecord2.PowerOff + " PowerOff ");
Console.WriteLine( statusrecord.InverterRecord2.WorkingMode + " WorkingMode ");
Console.WriteLine( statusrecord.InverterRecord2.GridSwitchMethod + " GridSwitchMethod ");
Console.WriteLine( statusrecord.InverterRecord2.ThreePhaseWireSystem + " ThreePhaseWireSystem ");
/*
Console.WriteLine( statusrecord.InverterRecord1.RepetitiveWeeks + " RepetitiveWeeks ");
Console.WriteLine( statusrecord.InverterRecord1.EffectiveStartDate + " EffectiveStartDate ");
Console.WriteLine( statusrecord.InverterRecord1.EffectiveEndDate + " EffectiveEndDate ");
Console.WriteLine( statusrecord.InverterRecord1.ChargingPowerPeriod1 + " ChargingPowerPeriod1 ");
Console.WriteLine( statusrecord.InverterRecord1.DishargingPowerPeriod1 + " dischargingPowerPeriod1 ");
Console.WriteLine( statusrecord.InverterRecord1.ChargeStartTimePeriod1 + " ChargeStartTimePeriod1 ");
Console.WriteLine( statusrecord.InverterRecord1.ChargeEndTimePeriod1 + " ChargeEndTimePeriod1 ");
Console.WriteLine( statusrecord.InverterRecord1.DischargeStartTimePeriod1 + " DischargeStartTimePeriod1 ");
Console.WriteLine( statusrecord.InverterRecord1.DischargeEndTimePeriod1 + " DischargeEndTimePeriod1 ");*/
SendSalimaxStateAlarm(GetSodiHomeStateAlarm(statusrecord),statusrecord); SendSalimaxStateAlarm(GetSodiHomeStateAlarm(statusrecord),statusrecord);
statusrecord.ControlConstants(); statusrecord.ControlConstants();
@ -213,18 +169,20 @@ internal static class Program
Console.WriteLine(startWritingTime.ToString("HH:mm:ss.fff ") +"start Writing"); Console.WriteLine(startWritingTime.ToString("HH:mm:ss.fff ") +"start Writing");
statusrecord?.Config.Save(); // save the config file statusrecord?.Config.Save(); // save the config file
if (statusrecord is { Config.ControlPermission: true }) if (statusrecord is { Config.ControlPermission: true, InverterRecord.Devices: not null })
{ {
Console.WriteLine(" We have the Right to Write"); Console.WriteLine("We have the Right to Write");
sinexcelDevice1.Write(statusrecord.InverterRecord1);
sinexcelDevice2.Write(statusrecord.InverterRecord2);
foreach (var pair in devices.Zip(statusrecord.InverterRecord.Devices))
pair.First.Write(pair.Second);
} }
else else
{ {
Console.WriteLine(" Nooooooo We cant' have the Right to Write"); Console.WriteLine("Nooooooo We can't have the Right to Write");
} }
var stop = DateTime.Now;
Console.WriteLine("***************************** Writing finished *********************************************");
Console.WriteLine(stop.ToString("HH:mm:ss.fff ")+ "Cycle end");
return statusrecord; return statusrecord;
} }
catch (CrcException e) catch (CrcException e)
@ -240,59 +198,215 @@ internal static class Program
} }
} }
// this is synchronous because :
//
// it only appends one line once per hour and once per day
//
// it should not meaningfully affect a 10-second loop
private static async Task CreateAggregatedData(StatusRecord statusRecord)
{
DateTime now = DateTime.Now;
string baseFolder = AppContext.BaseDirectory;
// 1) Finalize previous hour if hour changed
var hourlyData = EnergyAggregation.ProcessHourlyData(statusRecord, now);
/*if (hourlyData != null)
{
AggregatedDataFileWriter.AppendHourlyData(hourlyData, baseFolder);
}*/
if (hourlyData != null)
{
AggregatedDataFileWriter.AppendHourlyData(hourlyData, baseFolder);
if (_pendingDailyData != null && hourlyData.Timestamp.Hour == 23)
{
AggregatedDataFileWriter.AppendDailyData(_pendingDailyData, baseFolder);
_pendingDailyData = null;
}
}
// 2) Save daily line near end of day
var dailyData = EnergyAggregation.TryCreateDailyData(statusRecord, now);
if (dailyData != null)
{
_pendingDailyData = dailyData;
//AggregatedDataFileWriter.AppendDailyData(dailyData, baseFolder);
}
// 3) After midnight, upload yesterday's completed file once
var yesterday = now.Date.AddDays(-1);
if (now.Hour == 0 && now.Minute == 0)
{
if (_lastUploadedAggregatedDate != yesterday)
{
Console.WriteLine(" We are inside the lastuploaded Aggregate");
string filePath = Path.Combine(
baseFolder,
"AggregatedData",
yesterday.ToString("ddMMyyyy") + ".json");
bool uploaded = await PushAggregatedFileToS3(filePath, statusRecord);
if (uploaded)
{
_lastUploadedAggregatedDate = yesterday;
Console.WriteLine($"Uploaded aggregated file for {yesterday:ddMMyyyy}");
}
else
{
Console.WriteLine($"Uploaded failed for {yesterday:ddMMyyyy}");
}
}
}
}
private static async Task<Boolean> PushAggregatedFileToS3( String localFilePath, StatusRecord statusRecord)
{
var s3Config = statusRecord.Config.S3;
if (s3Config is null)
return false;
try
{
if (!File.Exists(localFilePath))
{
Console.WriteLine($"File not found: {localFilePath}");
return false;
}
var jsonString = await File.ReadAllTextAsync(localFilePath);
// Example S3 object name: 09032026.json
var s3Path = Path.GetFileName(localFilePath);
var request = s3Config.CreatePutRequest(s3Path);
var base64String = Convert.ToBase64String(Encoding.UTF8.GetBytes(jsonString));
using var content = new StringContent(base64String, Encoding.UTF8, "application/base64");
Console.WriteLine("Sending Content-Type: application/base64; charset=utf-8");
Console.WriteLine($"S3 Path: {s3Path}");
var response = await request.PutAsync(content);
if (response.StatusCode != 200)
{
Console.WriteLine("ERROR: PUT");
var error = await response.GetStringAsync();
Console.WriteLine(error);
return false;
}
Console.WriteLine($"Uploaded successfully: {s3Path}");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"PushAggregatedFileToS3 failed: {ex.Message}");
return false;
}
}
private static void ControlConstants(this StatusRecord? statusrecord) private static void ControlConstants(this StatusRecord? statusrecord)
{ {
if (statusrecord == null) return; if (statusrecord?.InverterRecord?.Devices == null) return;
statusrecord.InverterRecord1.Battery1BackupSoc = (Single)statusrecord.Config.MinSoc ; // Compute once (same for all inverters)
statusrecord.InverterRecord1.Battery2BackupSoc = (Single)statusrecord.Config.MinSoc ; var config = statusrecord.Config;
statusrecord.InverterRecord1.RepetitiveWeeks = SinexcelWeekDays.All;
var isChargePeriod = IsNowInsideDateAndTime(
config.StartTimeChargeandDischargeDayandTime,
config.StopTimeChargeandDischargeDayandTime
);
var isChargePeriod = IsNowInsideDateAndTime(statusrecord.Config.StartTimeChargeandDischargeDayandTime, statusrecord.Config.StopTimeChargeandDischargeDayandTime); foreach (var inverter in statusrecord.InverterRecord.Devices)
Console.WriteLine("Are we inside the charge/Discharge time " + isChargePeriod);
if (statusrecord.Config.OperatingPriority != TimeChargeDischarge)
{ {
statusrecord.InverterRecord1.WorkingMode = statusrecord.Config.OperatingPriority; // constants for every inverter
inverter.Battery1BackupSoc = (float)config.MinSoc;
inverter.Battery2BackupSoc = (float)config.MinSoc;
inverter.RepetitiveWeeks = SinexcelWeekDays.All;
var operatingMode = config.OperatingPriority switch
{
OperatingPriority.LoadPriority => SpontaneousSelfUse,
OperatingPriority.BatteryPriority => TimeChargeDischarge,
OperatingPriority.GridPriority => PrioritySellElectricity,
_ => SpontaneousSelfUse
};
if (operatingMode!= TimeChargeDischarge)
{
inverter.WorkingMode = operatingMode;
} }
else if (statusrecord.Config.OperatingPriority == TimeChargeDischarge && isChargePeriod) else if (isChargePeriod)
{ {
statusrecord.InverterRecord1.WorkingMode = statusrecord.Config.OperatingPriority; inverter.WorkingMode = operatingMode;
inverter.EffectiveStartDate = config.StartTimeChargeandDischargeDayandTime.Date;
inverter.EffectiveEndDate = config.StopTimeChargeandDischargeDayandTime.Date;
if (statusrecord.Config.TimeChargeandDischargePower > 0) var power = config.TimeChargeandDischargePower;
if (power > 0)
{ {
statusrecord.InverterRecord1.EffectiveStartDate = statusrecord.Config.StartTimeChargeandDischargeDayandTime.Date; inverter.ChargingPowerPeriod1 = Math.Abs(power);
statusrecord.InverterRecord1.EffectiveEndDate = statusrecord.Config.StopTimeChargeandDischargeDayandTime.Date; inverter.ChargeStartTimePeriod1 = config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.ChargingPowerPeriod1 = Math.Abs(statusrecord.Config.TimeChargeandDischargePower); inverter.ChargeEndTimePeriod1 = config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.ChargeStartTimePeriod1 = statusrecord.Config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.ChargeEndTimePeriod1 = statusrecord.Config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.DischargeStartTimePeriod1 = TimeSpan.Zero; inverter.DischargeStartTimePeriod1 = TimeSpan.Zero;
statusrecord.InverterRecord1.DischargeEndTimePeriod1 = TimeSpan.Zero; inverter.DischargeEndTimePeriod1 = TimeSpan.Zero;
} }
else else
{ {
statusrecord.InverterRecord1.EffectiveStartDate = statusrecord.Config.StartTimeChargeandDischargeDayandTime.Date; inverter.DishargingPowerPeriod1 = Math.Abs(power);
statusrecord.InverterRecord1.EffectiveEndDate = statusrecord.Config.StopTimeChargeandDischargeDayandTime.Date; inverter.DischargeStartTimePeriod1 = config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.DishargingPowerPeriod1 = Math.Abs(statusrecord.Config.TimeChargeandDischargePower); inverter.DischargeEndTimePeriod1 = config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.DischargeStartTimePeriod1 = statusrecord.Config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.DischargeEndTimePeriod1 = statusrecord.Config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.ChargeStartTimePeriod1 = TimeSpan.Zero; inverter.ChargeStartTimePeriod1 = TimeSpan.Zero;
statusrecord.InverterRecord1.ChargeEndTimePeriod1 = TimeSpan.Zero; inverter.ChargeEndTimePeriod1 = TimeSpan.Zero;
} }
} }
else else
{ {
statusrecord.InverterRecord1.WorkingMode = SpontaneousSelfUse; inverter.WorkingMode = SpontaneousSelfUse;
} }
statusrecord.InverterRecord1.PowerOn = 1;
statusrecord.InverterRecord1.PowerOff = 0; inverter.PowerOn = 1;
//statusrecord.InverterRecord.FaultClearing = 1; inverter.PowerOff = 0;
}
}
static void PrintInverterData(SinexcelRecord r, int index)
{
Console.WriteLine($" ************************************************ Inverter {index} ************************************************ ");
//Console.WriteLine($"{r.SystemDateTime} SystemDateTime");
Console.WriteLine($"{r.TotalPhotovoltaicPower} TotalPhotovoltaicPower");
Console.WriteLine($"{r.TotalBatteryPower} TotalBatteryPower");
Console.WriteLine($"{r.TotalLoadPower} TotalLoadPower");
Console.WriteLine($"{r.TotalGridPower} TotalGridPower");
Console.WriteLine($"{r.Battery1Power} Battery1Power");
Console.WriteLine($"{r.Battery1Soc} Battery1Soc");
Console.WriteLine($"{r.Battery1BackupSoc} Battery1BackupSoc");
Console.WriteLine($"{r.Battery1MinSoc} Battery1MinSoc");
Console.WriteLine($"{r.Battery2Power} Battery2Power");
Console.WriteLine($"{r.Battery2Soc} Battery2Soc");
Console.WriteLine($"{r.Battery2BackupSoc} Battery2BackupSoc");
Console.WriteLine($"{r.Battery2MinSoc} Battery2MinSoc");
Console.WriteLine($"{r.EnableGridExport} EnableGridExport");
Console.WriteLine($"{r.PowerGridExportLimit} PowerGridExportLimit");
Console.WriteLine($"{r.PowerOn} PowerOn");
Console.WriteLine($"{r.PowerOff} PowerOff");
Console.WriteLine($"{r.WorkingMode} WorkingMode");
Console.WriteLine($"{r.GridSwitchMethod} GridSwitchMethod");
Console.WriteLine($"{r.ThreePhaseWireSystem} ThreePhaseWireSystem");
Console.WriteLine();
} }
private static bool IsNowInsideDateAndTime(DateTime effectiveStart, DateTime effectiveEnd) private static bool IsNowInsideDateAndTime(DateTime effectiveStart, DateTime effectiveEnd)
@ -309,7 +423,7 @@ internal static class Program
private static StatusMessage GetSodiHomeStateAlarm(StatusRecord? record) private static StatusMessage GetSodiHomeStateAlarm(StatusRecord? record)
{ {
var s3Bucket = Config.Load().S3?.Bucket; var s3Bucket = record?.Config.S3?.Bucket; // this should not load the config file, only use the one from status record TO change this in other project
var alarmList = new List<AlarmOrWarning>(); var alarmList = new List<AlarmOrWarning>();
var warningList = new List<AlarmOrWarning>(); var warningList = new List<AlarmOrWarning>();
@ -424,12 +538,6 @@ internal static class Program
var stateChanged = currentSalimaxState.Status != _prevSodiohomeAlarmState; var stateChanged = currentSalimaxState.Status != _prevSodiohomeAlarmState;
var contentChanged = HasErrorsOrWarningsChanged(currentSalimaxState); var contentChanged = HasErrorsOrWarningsChanged(currentSalimaxState);
var needsHeartbeat = (DateTime.Now - _lastHeartbeatTime).TotalSeconds >= HeartbeatIntervalSeconds; var needsHeartbeat = (DateTime.Now - _lastHeartbeatTime).TotalSeconds >= HeartbeatIntervalSeconds;
Console.WriteLine($"subscribedNow={subscribedNow}");
Console.WriteLine($"_subscribedToQueue={_subscribedToQueue}");
Console.WriteLine($"stateChanged={stateChanged}");
Console.WriteLine($"contentChanged={contentChanged}");
Console.WriteLine($"needsHeartbeat={needsHeartbeat}");
Console.WriteLine($"s3Bucket null? {s3Bucket == null}");
if (s3Bucket == null) if (s3Bucket == null)
{ {
@ -530,43 +638,44 @@ internal static class Program
{ {
var modbusData = new Dictionary<String, UInt16>(); var modbusData = new Dictionary<String, UInt16>();
try
{
// SYSTEM DATA // SYSTEM DATA
var result1 = ConvertToModbusRegisters((status.Config.ModbusProtcolNumber * 10), "UInt16", 30001); // this to be updated to modbusTCP version var result1 = ConvertToModbusRegisters((status.Config.ModbusProtcolNumber * 10), "UInt16", 30001); // this to be updated to modbusTCP version
var result2 = ConvertToModbusRegisters(status.InverterRecord1.SystemDateTime.ToUnixTime(), "UInt32", 30002); var result2 = ConvertToModbusRegisters(DateTimeOffset.Now.ToUnixTimeSeconds(), "UInt32", 30002);
// SYSTEM DATA // SYSTEM DATA
var result3 = ConvertToModbusRegisters(status.InverterRecord1.WorkingMode, "UInt16", 30004); var result3 = ConvertToModbusRegisters(status.InverterRecord.OperatingPriority, "UInt16", 30004);
// BATTERY SUMMARY (assuming single battery [0]) // this to be improved
var result4 = ConvertToModbusRegisters((status.Config.BatteriesCount), "UInt16", 31000); var result4 = ConvertToModbusRegisters((status.Config.BatteriesCount), "UInt16", 31000);
var result8 = ConvertToModbusRegisters((status.InverterRecord1.Battery1Voltage.Value * 10), "UInt16", 31001); var result8 = ConvertToModbusRegisters((0), "UInt16", 31001); // this is ignored as dosen't exist in Sinexcel
var result12 = ConvertToModbusRegisters((status.InverterRecord1.Battery2Voltage.Value * 10), "Int16", 31002); var result12 = ConvertToModbusRegisters((status.InverterRecord.AvgBatteryVoltage.Value * 10), "Int16", 31002);
var result13 = ConvertToModbusRegisters((status.InverterRecord1.Battery1Current.Value * 10), "Int32", 31003); var result13 = ConvertToModbusRegisters((status.InverterRecord.TotalBatteryCurrent.Value * 10), "Int32", 31003);
var result16 = ConvertToModbusRegisters((status.InverterRecord1.Battery2Current.Value * 10), "Int32", 31005); var result16 = ConvertToModbusRegisters((status.InverterRecord.AvgBatterySoc.Value * 100), "UInt16", 31005);
var result9 = ConvertToModbusRegisters((status.InverterRecord1.Battery1Soc.Value * 100), "UInt16", 31007); var result9 = ConvertToModbusRegisters((status.InverterRecord.TotalBatteryPower.Value * 10), "Int32", 31006);
var result14 = ConvertToModbusRegisters((status.InverterRecord1.Battery2Soc.Value * 100), "UInt16", 31008); var result14 = ConvertToModbusRegisters((status.InverterRecord.MinSoc.Value * 100), "UInt16", 31008);
var result5 = ConvertToModbusRegisters((status.InverterRecord1.TotalBatteryPower.Value * 10), "Int32", 31009); var result55 = ConvertToModbusRegisters(100 * 100, "UInt16", 31009); //this is ignored as dosen't exist in Sinexcel
var result5 = ConvertToModbusRegisters((status.InverterRecord.AvgBatterySoh.Value * 100), "UInt16", 31009);
var result7 = ConvertToModbusRegisters((status.InverterRecord1.Battery1BackupSoc * 100), "UInt16", 31011);
var result20 = ConvertToModbusRegisters((status.InverterRecord1.Battery2BackupSoc * 100), "UInt16", 31012);
var result15 = ConvertToModbusRegisters((status.InverterRecord1.Battery1Soh.Value * 100), "UInt16", 31013);
var result26 = ConvertToModbusRegisters((status.InverterRecord1.Battery2Soh.Value * 100), "UInt16", 31014);
var result21 = ConvertToModbusRegisters((status.InverterRecord1.Battery1MaxChargingCurrent * 10), "UInt16", 31016);
var result22 = ConvertToModbusRegisters((status.InverterRecord1.Battery1MaxDischargingCurrent * 10), "UInt16", 31017);
var result18 = ConvertToModbusRegisters((status.InverterRecord1.PvTotalPower * 10), "UInt32", 32000); var result7 = ConvertToModbusRegisters((status.InverterRecord.AvgBatteryTemp.Value * 100), "Int16", 31011);
var result19 = ConvertToModbusRegisters((status.InverterRecord1.GridPower * 10), "Int32", 33000); var result20 = ConvertToModbusRegisters((status.InverterRecord.MaxChargeCurrent.Value * 10), "UInt16", 31012);
var result23 = ConvertToModbusRegisters((status.InverterRecord1.GridVoltageFrequency * 10), "UInt16", 33002); var result15 = ConvertToModbusRegisters((status.InverterRecord.MaxDischargingCurrent.Value * 10), "UInt16", 31013);
var result26 = ConvertToModbusRegisters(60 * 10, "UInt16", 31014); //this is ignored as dosen't exist in Sinexcel
var result24 = ConvertToModbusRegisters((status.InverterRecord1.WorkingMode), "UInt16", 34000); var result18 = ConvertToModbusRegisters((status.InverterRecord.TotalPhotovoltaicPower.Value * 10), "UInt32", 32000);
var result25 = ConvertToModbusRegisters((status.InverterRecord1.InverterActivePower * 10), "Int32", 34001); var result19 = ConvertToModbusRegisters((status.InverterRecord.TotalGridPower.Value * 10), "Int32", 33000);
var result29 = ConvertToModbusRegisters((status.InverterRecord1.EnableGridExport ), "UInt16", 34003); var result23 = ConvertToModbusRegisters((status.InverterRecord.GridFrequency.Value * 10), "UInt16", 33002);
var result27 = ConvertToModbusRegisters((status.InverterRecord1.PowerGridExportLimit ), "Int16", 34004);
var result24 = ConvertToModbusRegisters((status.InverterRecord.OperatingPriority), "UInt16", 34000);
var result25 = ConvertToModbusRegisters((status.InverterRecord.InverterPower.Value * 10), "Int32", 34001);
var result29 = ConvertToModbusRegisters((status.InverterRecord.EnableGridExport ), "UInt16", 35002);
var result27 = ConvertToModbusRegisters((status.InverterRecord.GridExportPower.Value ), "Int16", 35003);
// Merge all results into one dictionary // Merge all results into one dictionary
var allResults = new[] var allResults = new[]
{ {
result1, result2, result3, result4, result5, result23, result24, result25, result29, result27, result26, result7, result8, result9, result16, result20, result12, result13, result14, result15, result18, result19, result21, result22 result1, result2, result3, result4, result5, result23, result24, result25, result29, result27, result26, result7, result8, result9, result16, result20, result12, result13, result14, result15, result18, result19
, result55
}; };
foreach (var result in allResults) foreach (var result in allResults)
@ -580,12 +689,17 @@ internal static class Program
var json = JsonSerializer.Serialize(modbusData, new JsonSerializerOptions { WriteIndented = true }); var json = JsonSerializer.Serialize(modbusData, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync("/home/inesco/SodiStoreHome/ModbusTCP/modbus_tcp_data.json", json); await File.WriteAllTextAsync("/home/inesco/SodiStoreHome/ModbusTCP/modbus_tcp_data.json", json);
//Console.WriteLine("JSON file written successfully."); // Console.WriteLine("JSON file written successfully.");
//Console.WriteLine(json); // Console.WriteLine(json);
var stopTime = DateTime.Now;
Console.WriteLine(stopTime.ToString("HH:mm:ss.fff" )+ " Finish the loop");
return true; return true;
} }
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
private static Dictionary<string, ushort> ConvertToModbusRegisters(object value, string outputType, int startingAddress) private static Dictionary<string, ushort> ConvertToModbusRegisters(object value, string outputType, int startingAddress)
{ {

View File

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Text.Json; using System.Text.Json;
using InnovEnergy.App.SinexcelCommunication.DataTypes;
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType; using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType;
using InnovEnergy.Lib.Utils; using InnovEnergy.Lib.Utils;
using static System.Text.Json.JsonSerializer; using static System.Text.Json.JsonSerializer;
@ -10,17 +11,21 @@ namespace InnovEnergy.App.SinexcelCommunication.SystemConfig;
[SuppressMessage("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic access otherwise can break functionality when trimming application code")] [SuppressMessage("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic access otherwise can break functionality when trimming application code")]
public class Config public class Config
{ {
private static String DefaultConfigFilePath => Path.Combine(Environment.CurrentDirectory, "config.json"); private static String DefaultConfigFilePath => Path.Combine(Environment.CurrentDirectory, "config.json");
private static DateTime DefaultDatetime => new(2025, 01, 01, 09, 00, 00); private static DateTime DefaultDatetime => new(2025, 01, 01, 09, 00, 00);
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public required DeviceConfig Devices { get; set; }
//public required Boolean DynamicPricingEnabled { get; set; }
//public required DynamicPricingMode DynamicPricingMode { get; set; }
//public required Decimal CheapPrice { get; set; }
//public required Decimal HighPrice { get; set; }
public required Double MinSoc { get; set; } public required Double MinSoc { get; set; }
public required Double GridSetPoint { get; set; } public required Double GridSetPoint { get; set; }
public required Double MaximumDischargingCurrent { get; set; } public required Double MaximumDischargingCurrent { get; set; }
public required Double MaximumChargingCurrent { get; set; } public required Double MaximumChargingCurrent { get; set; }
public required WorkingMode OperatingPriority { get; set; } public required OperatingPriority OperatingPriority { get; set; }
public required Int16 BatteriesCount { get; set; } public required Int16 BatteriesCount { get; set; }
public required Int16 ClusterNumber { get; set; } public required Int16 ClusterNumber { get; set; }
public required Int16 PvNumber { get; set; } public required Int16 PvNumber { get; set; }
@ -35,14 +40,25 @@ public class Config
public required S3Config? S3 { get; set; } public required S3Config? S3 { get; set; }
private static String? LastSavedData { get; set; } private static String? LastSavedData { get; set; }
private static DateTime? LoadedWriteTimeUtc { get; set; }
public static Config Default => new() public static Config Default => new()
{ {
Devices = new ()
{
Serial = new() {BaudRate = 115200, Parity = 0, StopBits = 1, DataBits = 8},
Inverter1 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB0", SlaveId = 1},
Inverter2 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB1", SlaveId = 1},
Inverter3 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB3", SlaveId = 1},
Inverter4 = new() {DeviceState = DeviceState.Measured, Port = "/dev/ttyUSB4", SlaveId = 1},
},
//DynamicPricingEnabled = false,
//DynamicPricingMode = DynamicPricingMode.Disabled,
MinSoc = 20, MinSoc = 20,
GridSetPoint = 0, GridSetPoint = 0,
MaximumChargingCurrent = 180, MaximumChargingCurrent = 180,
MaximumDischargingCurrent = 180, MaximumDischargingCurrent = 180,
OperatingPriority = WorkingMode.TimeChargeDischarge, OperatingPriority = OperatingPriority.LoadPriority,
BatteriesCount = 0, BatteriesCount = 0,
ClusterNumber = 0, ClusterNumber = 0,
PvNumber = 0, PvNumber = 0,
@ -67,6 +83,10 @@ public class Config
{ {
var configFilePath = path ?? DefaultConfigFilePath; var configFilePath = path ?? DefaultConfigFilePath;
var currentWriteTime = File.GetLastWriteTimeUtc(configFilePath);
if (currentWriteTime != LoadedWriteTimeUtc)
throw new IOException("Config file changed on disk since it was loaded; refusing to overwrite."); // to prevent an overwriting while an external changes happended in the meantime
try try
{ {
var jsonString = Serialize(this, JsonOptions); var jsonString = Serialize(this, JsonOptions);
@ -84,13 +104,15 @@ public class Config
throw; throw;
} }
} }
/*
public static Config Load(String? path = null) public static Config Load(String? path = null)
{ {
var configFilePath = path ?? DefaultConfigFilePath; var configFilePath = path ?? DefaultConfigFilePath;
try try
{ {
var jsonString = File.ReadAllText(configFilePath); var jsonString = File.ReadAllText(configFilePath);
// LoadedWriteTimeUtc = File.GetLastWriteTimeUtc(configFilePath);
return Deserialize<Config>(jsonString)!; return Deserialize<Config>(jsonString)!;
} }
catch (Exception e) catch (Exception e)
@ -98,6 +120,31 @@ public class Config
$"Failed to read config file {configFilePath}, using default config\n{e}".WriteLine(); $"Failed to read config file {configFilePath}, using default config\n{e}".WriteLine();
return Default; return Default;
} }
}*/
public static Config Load(string? path = null)
{
var configFilePath = path ?? DefaultConfigFilePath; // ✅ handle null first
try
{
// Now safe to call any File/Path API
var json = File.ReadAllText(configFilePath);
var cfg = Deserialize<Config>(json, JsonOptions)
?? throw new InvalidOperationException("Config deserialized to null.");
// Optional: store last write time / last saved json
LoadedWriteTimeUtc = File.GetLastWriteTimeUtc(configFilePath);
LastSavedData = Serialize(cfg, JsonOptions); // if you use the save-skip logic
return cfg;
}
catch (Exception e)
{
$"Failed to read config file {configFilePath}\n{e}".WriteLine();
throw;
}
} }
public static async Task<Config> LoadAsync(String? path = null) public static async Task<Config> LoadAsync(String? path = null)

View File

@ -0,0 +1,11 @@
namespace InnovEnergy.App.SinexcelCommunication.SystemConfig;
public record DeviceConfig
{
public required SerialLineConfig Serial { get; init; }
public required SodiDevice Inverter1 { get; init; }
public required SodiDevice Inverter2 { get; init; }
public required SodiDevice Inverter3 { get; init; }
public required SodiDevice Inverter4 { get; init; }
}

View File

@ -0,0 +1,8 @@
namespace InnovEnergy.App.SinexcelCommunication.SystemConfig;
public enum DeviceState
{
Disabled,
Measured,
Computed
}

View File

@ -0,0 +1,12 @@
using System.IO.Ports;
namespace InnovEnergy.App.SinexcelCommunication.SystemConfig;
public sealed class SerialLineConfig
{
public required Int32 BaudRate { get; init; } = 115200;
public required Parity Parity { get; init; } = 0; //none
public required Int32 DataBits { get; init; } = 8;
public required Int32 StopBits { get; init; } = 1;
}

View File

@ -0,0 +1,8 @@
namespace InnovEnergy.App.SinexcelCommunication.SystemConfig;
public class SodiDevice
{
public required DeviceState DeviceState { get; init; }
public required String Port { get; init; }
public required Byte SlaveId { get; init; }
}

View File

@ -6,12 +6,14 @@ username='inesco'
root_password='Sodistore0918425' root_password='Sodistore0918425'
release_flag_file="./bin/Release/$dotnet_version/linux-arm64/publish/.release.flag" release_flag_file="./bin/Release/$dotnet_version/linux-arm64/publish/.release.flag"
DOTNET="/snap/dotnet-sdk_60/current/dotnet"
set -e set -e
echo -e "\n============================ Build ============================\n" echo -e "\n============================ Build ============================\n"
dotnet publish \ "$DOTNET" publish \
./SinexcelCommunication.csproj \ ./SinexcelCommunication.csproj \
-p:PublishTrimmed=false \ -p:PublishTrimmed=false \
-c Release \ -c Release \

View File

@ -50,3 +50,9 @@ public enum SinexcelMachineMode
Single = 0, // Default Single = 0, // Default
Parallel = 1 Parallel = 1
} }
public enum EnablePowerLimitation
{
Prohibited = 0, // Default
Enable = 1
}

View File

@ -1,3 +1,4 @@
using System.ComponentModel;
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType; using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType;
using InnovEnergy.Lib.Units; using InnovEnergy.Lib.Units;
using InnovEnergy.Lib.Units.Power; using InnovEnergy.Lib.Units.Power;
@ -193,7 +194,7 @@ public partial class SinexcelRecord
private Int16 _factorFromKwtoW = 1000; private readonly Int16 _factorFromKwtoW = 1000;
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
// Public API — Decoded Float Values // Public API — Decoded Float Values
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
@ -295,11 +296,12 @@ public partial class SinexcelRecord
public UInt16 Minute => (UInt16) ConvertBitPatternToFloat(_minute); public UInt16 Minute => (UInt16) ConvertBitPatternToFloat(_minute);
public UInt16 Second => (UInt16) ConvertBitPatternToFloat(_second); public UInt16 Second => (UInt16) ConvertBitPatternToFloat(_second);
public DateTime SystemDateTime => new(Year, Month, Day, Hour, Minute, Second); // public DateTime SystemDateTime => new(Year, Month, Day, Hour, Minute, Second);
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
// Diesel Generator Measurements // Diesel Generator Measurements
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
/*
public Voltage DieselGenAPhaseVoltage => ConvertBitPatternToFloat(_dieselGenAPhaseVoltage); public Voltage DieselGenAPhaseVoltage => ConvertBitPatternToFloat(_dieselGenAPhaseVoltage);
public Voltage DieselGenBPhaseVoltage => ConvertBitPatternToFloat(_dieselGenBPhaseVoltage); public Voltage DieselGenBPhaseVoltage => ConvertBitPatternToFloat(_dieselGenBPhaseVoltage);
public Voltage DieselGenCPhaseVoltage => ConvertBitPatternToFloat(_dieselGenCPhaseVoltage); public Voltage DieselGenCPhaseVoltage => ConvertBitPatternToFloat(_dieselGenCPhaseVoltage);
@ -324,7 +326,7 @@ public partial class SinexcelRecord
public ReactivePower DieselGenAPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenAPhaseReactivePower) * _factorFromKwtoW; public ReactivePower DieselGenAPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenAPhaseReactivePower) * _factorFromKwtoW;
public ReactivePower DieselGenBPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenBPhaseReactivePower) * _factorFromKwtoW; public ReactivePower DieselGenBPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenBPhaseReactivePower) * _factorFromKwtoW;
public ReactivePower DieselGenCPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenCPhaseReactivePower) * _factorFromKwtoW; public ReactivePower DieselGenCPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenCPhaseReactivePower) * _factorFromKwtoW;*/
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
// Photovoltaic and Battery Measurements // Photovoltaic and Battery Measurements
@ -645,14 +647,14 @@ public partial class SinexcelRecord
set => _battery2BackupSOC = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0); set => _battery2BackupSOC = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
} }
// to be tested // to be tested
public float EnableGridExport public EnablePowerLimitation EnableGridExport
{ {
get => BitConverter.Int32BitsToSingle(unchecked((int)_enableGridExport)); get => (EnablePowerLimitation)ConvertBitPatternToFloat(_enableGridExport); //(Boolean)BitConverter.Int32BitsToSingle(unchecked((int)_enableGridExport));
set => _enableGridExport = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0); set => _enableGridExport = (UInt32)value;
} }
public float PowerGridExportLimit public ActivePower PowerGridExportLimit
{ {
get => BitConverter.Int32BitsToSingle(unchecked((int)_powerGridExportLimit)); get => BitConverter.Int32BitsToSingle(unchecked((int)_powerGridExportLimit));
set => _powerGridExportLimit = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0); set => _powerGridExportLimit = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
@ -813,6 +815,7 @@ public partial class SinexcelRecord
public Percent Battery1SocSecondvalue => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab1Soc)); // 0xB106 % public Percent Battery1SocSecondvalue => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab1Soc)); // 0xB106 %
public Percent Battery1Soh => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab1Soh)); // 0xB108 % public Percent Battery1Soh => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab1Soh)); // 0xB108 %
// Energy (kW·h) // Energy (kW·h)
public float Battery2TotalChargingEnergy => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2TotalChargingEnergy)); // 0xB1FC public float Battery2TotalChargingEnergy => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2TotalChargingEnergy)); // 0xB1FC
public float Battery2TotalDischargedEnergy => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2TotalDischargedEnergy)); // 0xB1FE public float Battery2TotalDischargedEnergy => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2TotalDischargedEnergy)); // 0xB1FE
@ -820,6 +823,7 @@ public partial class SinexcelRecord
// Pack Voltage / Current / Temperature // Pack Voltage / Current / Temperature
public Voltage Battery2PackTotalVoltage => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2PackTotalVoltage)); // 0xB200 (0.01 V resolution) public Voltage Battery2PackTotalVoltage => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2PackTotalVoltage)); // 0xB200 (0.01 V resolution)
public Current Battery2PackTotalCurrent => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2PackTotalCurrent)); // 0xB202 (0.01 A resolution) public Current Battery2PackTotalCurrent => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2PackTotalCurrent)); // 0xB202 (0.01 A resolution)
public Temperature Battery2Temperature => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Temperature)); // 0xB104 (0.01 °C resolution per spec)
public Percent Battery2Socsecondvalue => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Soc)); // 0xB206 % public Percent Battery2Socsecondvalue => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Soc)); // 0xB206 %
public Percent Battery2Soh => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Soh)); // 0xB208 % public Percent Battery2Soh => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Soh)); // 0xB208 %
@ -931,4 +935,8 @@ public partial class SinexcelRecord
return BitConverter.ToSingle(bytes, 0); return BitConverter.ToSingle(bytes, 0);
} }
private static UInt32 ConvertFloatToBitPattern(float value)
{
return BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
}
} }

View File

@ -290,16 +290,17 @@ public partial class SinexcelRecord
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
// Date / Time Information // Date / Time Information
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
[HoldingRegister<UInt32>(4338)] private UInt32 _year; // 0x10F2 [HoldingRegister<UInt32>(4338/*, writable: true*/)] private UInt32 _year; // 0x10F2
[HoldingRegister<UInt32>(4340)] private UInt32 _month; // 0x10F4 [HoldingRegister<UInt32>(4340/*, writable: true*/)] private UInt32 _month; // 0x10F4
[HoldingRegister<UInt32>(4342)] private UInt32 _day; // 0x10F6 [HoldingRegister<UInt32>(4342/*, writable: true*/)] private UInt32 _day; // 0x10F6
[HoldingRegister<UInt32>(4344)] private UInt32 _hour; // 0x10F8 [HoldingRegister<UInt32>(4344/*, writable: true*/)] private UInt32 _hour; // 0x10F8
[HoldingRegister<UInt32>(4346)] private UInt32 _minute; // 0x10FA [HoldingRegister<UInt32>(4346/*, writable: true*/)] private UInt32 _minute; // 0x10FA
[HoldingRegister<UInt32>(4348)] private UInt32 _second; // 0x10FC [HoldingRegister<UInt32>(4348/*, writable: true*/)] private UInt32 _second; // 0x10FC
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
// Diesel Generator Measurements // Diesel Generator Measurements
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
/*
[HoldingRegister<UInt32>(4362)] private UInt32 _dieselGenAPhaseVoltage; // 0x110A [HoldingRegister<UInt32>(4362)] private UInt32 _dieselGenAPhaseVoltage; // 0x110A
[HoldingRegister<UInt32>(4364)] private UInt32 _dieselGenBPhaseVoltage; // 0x110C [HoldingRegister<UInt32>(4364)] private UInt32 _dieselGenBPhaseVoltage; // 0x110C
[HoldingRegister<UInt32>(4366)] private UInt32 _dieselGenCPhaseVoltage; // 0x110E [HoldingRegister<UInt32>(4366)] private UInt32 _dieselGenCPhaseVoltage; // 0x110E
@ -318,8 +319,8 @@ public partial class SinexcelRecord
[HoldingRegister<UInt32>(4410)] private UInt32 _dieselGenBPhaseActivePower; // 0x113A [HoldingRegister<UInt32>(4410)] private UInt32 _dieselGenBPhaseActivePower; // 0x113A
[HoldingRegister<UInt32>(4412)] private UInt32 _dieselGenCPhaseActivePower; // 0x113C [HoldingRegister<UInt32>(4412)] private UInt32 _dieselGenCPhaseActivePower; // 0x113C
[HoldingRegister<UInt32>(4414)] private UInt32 _dieselGenAPhaseReactivePower; // 0x113E [HoldingRegister<UInt32>(4414)] private UInt32 _dieselGenAPhaseReactivePower; // 0x113E
[HoldingRegister<UInt32>(4416)] private UInt32 _dieselGenBPhaseReactivePower; // 0x1140 [HoldingRegister<UInt32>(4416)] private UInt32 _dieselGenBPhaseReactivePower; // 0x1140*
[HoldingRegister<UInt32>(4418)] private UInt32 _dieselGenCPhaseReactivePower; // 0x1142 [HoldingRegister<UInt32>(4418)] private UInt32 _dieselGenCPhaseReactivePower; // 0x1142*/
// ─────────────────────────────────────────────── // ───────────────────────────────────────────────
// Photovoltaic and Battery Measurements // Photovoltaic and Battery Measurements

View File

@ -13,7 +13,7 @@
href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>Inesco Energy</title> <title>inesco energy</title>
</head> </head>
<body> <body>

View File

@ -34,15 +34,41 @@ function App() {
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
const username = searchParams.get('username'); const username = searchParams.get('username');
const { const {
accessToSalimax,
accessToSodiohome,
accessToSodistore,
accessToSodistoreGrid,
accessToSodistorePro,
setAccessToSalimax, setAccessToSalimax,
setAccessToSalidomo, setAccessToSalidomo,
setAccessToSodiohome, setAccessToSodiohome,
setAccessToSodistore, setAccessToSodistore,
setAccessToSodistoreGrid setAccessToSodistoreGrid,
setAccessToSodistorePro
} = useContext(ProductIdContext); } = useContext(ProductIdContext);
const defaultRoute = accessToSodiohome
? routes.sodiohome_installations
: accessToSodistorePro
? routes.sodistorepro_installations
: accessToSodistoreGrid
? routes.sodistoregrid_installations
: accessToSodistore
? routes.sodistore_installations
: accessToSalimax
? routes.installations
: routes.salidomo_installations;
const detectBrowserLanguage = (): string => {
const browserLang = navigator.language?.toLowerCase() || '';
if (browserLang.startsWith('de')) return 'de';
if (browserLang.startsWith('fr')) return 'fr';
if (browserLang.startsWith('it')) return 'it';
return 'en';
};
const [language, setLanguage] = useState<string>( const [language, setLanguage] = useState<string>(
() => localStorage.getItem('language') || currentUser?.language || 'en' () => localStorage.getItem('language') || currentUser?.language || detectBrowserLanguage()
); );
const onSelectLanguage = (lang: string) => { const onSelectLanguage = (lang: string) => {
@ -94,11 +120,19 @@ function App() {
const Login = Loader(lazy(() => import('src/components/login'))); const Login = Loader(lazy(() => import('src/components/login')));
const Users = Loader(lazy(() => import('src/content/dashboards/Users'))); const Users = Loader(lazy(() => import('src/content/dashboards/Users')));
const loginToResetPassword = () => { useEffect(() => {
if (!username || token) return;
axiosConfigWithoutToken axiosConfigWithoutToken
.post('/Login', null, { params: { username, password: '' } }) .post('/Login', null, { params: { username, password: '' } })
.then((response) => { .then((response) => {
if (response.data && response.data.token) { if (response.data && response.data.token) {
// Clear the username param from URL to prevent re-login loops
const url = new URL(window.location.href);
url.searchParams.delete('username');
url.searchParams.delete('reset');
window.history.replaceState({}, '', url.pathname);
setNewToken(response.data.token); setNewToken(response.data.token);
setUser(response.data.user); setUser(response.data.user);
setAccessToSalimax(response.data.accessToSalimax); setAccessToSalimax(response.data.accessToSalimax);
@ -106,25 +140,11 @@ function App() {
setAccessToSodiohome(response.data.accessToSodioHome); setAccessToSodiohome(response.data.accessToSodioHome);
setAccessToSodistore(response.data.accessToSodistoreMax); setAccessToSodistore(response.data.accessToSodistoreMax);
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid); setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
if (response.data.accessToSalimax) { setAccessToSodistorePro(response.data.accessToSodistorePro);
navigate(routes.installations);
} else if (response.data.accessToSalidomo) {
navigate(routes.salidomo_installations);
} else if (response.data.accessToSodistoreMax) {
navigate(routes.sodistore_installations);
} else if (response.data.accessToSodistoreGrid) {
navigate(routes.sodistoregrid_installations);
} else {
navigate(routes.sodiohome_installations);
}
} }
}) })
.catch(() => {}); .catch(() => {});
}; }, [username]);
if (username) {
loginToResetPassword();
}
if (!token) { if (!token) {
return ( return (
@ -158,8 +178,14 @@ function App() {
if (token && currentUser?.mustResetPassword) { if (token && currentUser?.mustResetPassword) {
return ( return (
<ThemeProvider> <ThemeProvider>
<IntlProvider
messages={getTranslations()}
locale={language}
defaultLocale="en"
>
<CssBaseline /> <CssBaseline />
<SetNewPassword></SetNewPassword> <SetNewPassword></SetNewPassword>
</IntlProvider>
</ThemeProvider> </ThemeProvider>
); );
} }
@ -177,11 +203,11 @@ function App() {
<Routes> <Routes>
<Route <Route
path={''} path={''}
element={<Navigate to={routes.installations}></Navigate>} element={<Navigate to={defaultRoute}></Navigate>}
></Route> ></Route>
<Route <Route
path={'login'} path={'login'}
element={<Navigate to={routes.installations}></Navigate>} element={<Navigate to={defaultRoute}></Navigate>}
></Route> ></Route>
<Route <Route
path="/" path="/"
@ -228,6 +254,15 @@ function App() {
} }
/> />
<Route
path={routes.sodistorepro_installations + '*'}
element={
<AccessContextProvider>
<SodioHomeInstallationTabs product={5} />
</AccessContextProvider>
}
/>
<Route <Route
path={routes.sodistoregrid_installations + '*'} path={routes.sodistoregrid_installations + '*'}
element={ element={
@ -241,7 +276,7 @@ function App() {
<Route path={routes.users + '*'} element={<Users />} /> <Route path={routes.users + '*'} element={<Users />} />
<Route <Route
path={'*'} path={'*'}
element={<Navigate to={routes.installations}></Navigate>} element={<Navigate to={defaultRoute}></Navigate>}
></Route> ></Route>
<Route path="ResetPassword" element={<ResetPassword />}></Route> <Route path="ResetPassword" element={<ResetPassword />}></Route>
</Route> </Route>

View File

@ -20,8 +20,9 @@ export function formatPowerForGraph(value, magnitude): { value: number } {
} }
} }
const result = negative === false ? value : -value;
return { return {
value: negative === false ? value : -value value: Math.round(result * 100) / 100
}; };
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -5,6 +5,7 @@
"sodistore_installations": "/sodistore_installations/", "sodistore_installations": "/sodistore_installations/",
"sodiohome_installations": "/sodiohome_installations/", "sodiohome_installations": "/sodiohome_installations/",
"sodistoregrid_installations": "/sodistoregrid_installations/", "sodistoregrid_installations": "/sodistoregrid_installations/",
"sodistorepro_installations": "/sodistorepro_installations/",
"installation": "installation/", "installation": "installation/",
"login": "/login/", "login": "/login/",
"forgotPassword": "/forgotPassword/", "forgotPassword": "/forgotPassword/",
@ -24,5 +25,6 @@
"detailed_view": "detailed_view/", "detailed_view": "detailed_view/",
"report": "report", "report": "report",
"installationTickets": "installationTickets", "installationTickets": "installationTickets",
"documents": "documents",
"tickets": "/tickets/" "tickets": "/tickets/"
} }

View File

@ -0,0 +1,95 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Divider,
Box
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
export const CURRENT_TERMS_VERSION = 2;
interface AcknowledgementDialogProps {
open: boolean;
onAcknowledge: () => void;
}
const AcknowledgementDialog: React.FC<AcknowledgementDialogProps> = ({
open,
onAcknowledge
}) => {
return (
<Dialog open={open} maxWidth="sm" fullWidth>
<DialogTitle>
<FormattedMessage
id="terms_dialog_title"
defaultMessage="Welcome to inesco energy"
/>
</DialogTitle>
<DialogContent dividers>
<Box mb={2}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
<FormattedMessage
id="terms_data_heading"
defaultMessage="Your Data"
/>
</Typography>
<Typography variant="body2">
<FormattedMessage
id="terms_data_body"
defaultMessage="Your installation data is securely stored in Switzerland. We do not share your data with third parties."
/>
</Typography>
</Box>
<Divider />
<Box my={2}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
<FormattedMessage
id="terms_ai_heading"
defaultMessage="AI-Powered Insights"
/>
</Typography>
<Typography variant="body2">
<FormattedMessage
id="terms_ai_body"
defaultMessage="We use an AI service hosted in the EU to provide diagnostics and insights for your installations. AI-generated results are recommendations and should be verified by qualified personnel."
/>
</Typography>
</Box>
<Divider />
<Box mt={2}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
<FormattedMessage
id="terms_cookies_heading"
defaultMessage="Browser Storage"
/>
</Typography>
<Typography variant="body2">
<FormattedMessage
id="terms_cookies_body"
defaultMessage="This platform stores login and preference settings in your browser to keep you signed in and remember your language choice."
/>
</Typography>
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={onAcknowledge}>
<FormattedMessage
id="terms_acknowledge_button"
defaultMessage="I understand"
/>
</Button>
</DialogActions>
</Dialog>
);
};
export default AcknowledgementDialog;

View File

@ -0,0 +1,110 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Divider,
Box
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
interface DataPrivacyDialogProps {
open: boolean;
onClose: () => void;
}
const DataPrivacyDialog: React.FC<DataPrivacyDialogProps> = ({
open,
onClose
}) => {
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>
<FormattedMessage
id="privacy_dialog_title"
defaultMessage="Data & Privacy"
/>
</DialogTitle>
<DialogContent dividers>
<Box mb={2}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
<FormattedMessage
id="privacy_data_heading"
defaultMessage="Where is my data stored?"
/>
</Typography>
<Typography variant="body2">
<FormattedMessage
id="privacy_data_body"
defaultMessage="Your installation data is stored on servers in Switzerland. Only authorized inesco energy personnel can access your data for support purposes."
/>
</Typography>
</Box>
<Divider />
<Box my={2}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
<FormattedMessage
id="privacy_ai_heading"
defaultMessage="How is AI used?"
/>
</Typography>
<Typography variant="body2">
<FormattedMessage
id="privacy_ai_body"
defaultMessage="We use an AI service hosted in the European Union to analyze your installation data and provide diagnostic insights. The AI processes technical data such as battery readings and error codes. AI recommendations should always be verified by qualified personnel."
/>
</Typography>
</Box>
<Divider />
<Box my={2}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
<FormattedMessage
id="privacy_browser_heading"
defaultMessage="What does my browser store?"
/>
</Typography>
<Typography variant="body2">
<FormattedMessage
id="privacy_browser_body"
defaultMessage="Your browser stores your login session to keep you signed in, and your language and theme preferences. No tracking or advertising cookies are used."
/>
</Typography>
</Box>
<Divider />
<Box mt={2}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
<FormattedMessage
id="privacy_access_heading"
defaultMessage="Who has access to my data?"
/>
</Typography>
<Typography variant="body2">
<FormattedMessage
id="privacy_access_body"
defaultMessage="Your data is not shared with third parties. It is used solely to operate the platform and provide you with insights about your installations."
/>
</Typography>
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={onClose}>
<FormattedMessage
id="privacy_close_button"
defaultMessage="Close"
/>
</Button>
</DialogActions>
</Dialog>
);
};
export default DataPrivacyDialog;

View File

@ -0,0 +1,222 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Chip,
Dialog,
IconButton,
List,
ListItem,
ListItemText,
Typography
} from '@mui/material';
import DownloadIcon from '@mui/icons-material/Download';
import DeleteIcon from '@mui/icons-material/Delete';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
export interface DocumentItem {
id: number;
ticketId?: number;
ticketCommentId?: number;
installationId?: number;
scope: number;
s3Key: string;
originalName: string;
contentType: string;
sizeBytes: number;
uploadedByUserId: number;
createdAt: string;
}
interface DocumentListProps {
ticketId?: number;
ticketCommentId?: number;
installationId?: number;
refreshKey?: number;
canDelete?: boolean;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function isImage(contentType: string): boolean {
return contentType.startsWith('image/');
}
function getFileIcon(contentType: string) {
if (contentType === 'application/pdf') return <PictureAsPdfIcon fontSize="small" color="error" />;
return <InsertDriveFileIcon fontSize="small" />;
}
function DocumentList({
ticketId,
ticketCommentId,
installationId,
refreshKey = 0,
canDelete = false
}: DocumentListProps) {
const [documents, setDocuments] = useState<DocumentItem[]>([]);
const [loading, setLoading] = useState(true);
const [previews, setPreviews] = useState<Record<number, string>>({});
const [expandedImage, setExpandedImage] = useState<string | null>(null);
const fetchDocuments = () => {
setLoading(true);
axiosConfig
.get('/GetDocuments', {
params: { ticketId, ticketCommentId, installationId }
})
.then((res) => {
if (Array.isArray(res.data)) setDocuments(res.data);
})
.catch(() => setDocuments([]))
.finally(() => setLoading(false));
};
useEffect(() => {
fetchDocuments();
}, [ticketId, ticketCommentId, installationId, refreshKey]);
// Load image thumbnails
useEffect(() => {
documents.forEach((doc) => {
if (isImage(doc.contentType) && !previews[doc.id]) {
axiosConfig
.get('/DownloadDocument', {
params: { id: doc.id },
responseType: 'blob'
})
.then((res) => {
const url = window.URL.createObjectURL(new Blob([res.data], { type: doc.contentType }));
setPreviews((prev) => ({ ...prev, [doc.id]: url }));
})
.catch(() => {});
}
});
}, [documents]);
// Clean up blob URLs on unmount
useEffect(() => {
return () => {
Object.values(previews).forEach((url) => window.URL.revokeObjectURL(url));
};
}, []);
const handleDownload = (doc: DocumentItem) => {
if (previews[doc.id]) {
const link = document.createElement('a');
link.href = previews[doc.id];
link.setAttribute('download', doc.originalName);
document.body.appendChild(link);
link.click();
link.remove();
return;
}
axiosConfig
.get('/DownloadDocument', {
params: { id: doc.id },
responseType: 'blob'
})
.then((res) => {
const url = window.URL.createObjectURL(new Blob([res.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', doc.originalName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
})
.catch(() => {});
};
const handleDelete = (doc: DocumentItem) => {
axiosConfig
.delete('/DeleteDocument', { params: { id: doc.id } })
.then(() => fetchDocuments())
.catch(() => {});
};
if (loading) return null;
if (documents.length === 0) return null;
return (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
<FormattedMessage id="attachments" defaultMessage="Attachments" />
<Chip label={documents.length} size="small" sx={{ ml: 1 }} />
</Typography>
<List dense disablePadding>
{documents.map((doc) => (
<ListItem
key={doc.id}
disableGutters
sx={{ alignItems: 'flex-start', flexDirection: 'column', gap: 0.5 }}
>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
{!isImage(doc.contentType) && (
<Box sx={{ mr: 1, display: 'flex', alignItems: 'center' }}>
{getFileIcon(doc.contentType)}
</Box>
)}
<ListItemText
primary={doc.originalName}
secondary={`${formatFileSize(doc.sizeBytes)}${new Date(doc.createdAt).toLocaleDateString()}`}
/>
<Box sx={{ ml: 'auto', flexShrink: 0 }}>
<IconButton size="small" onClick={() => handleDownload(doc)}>
<DownloadIcon fontSize="small" />
</IconButton>
{canDelete && (
<IconButton size="small" onClick={() => handleDelete(doc)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
</Box>
{isImage(doc.contentType) && previews[doc.id] && (
<Box
component="img"
src={previews[doc.id]}
alt={doc.originalName}
onClick={() => setExpandedImage(previews[doc.id])}
sx={{
maxWidth: 200,
maxHeight: 150,
borderRadius: 1,
cursor: 'pointer',
border: '1px solid',
borderColor: 'divider',
'&:hover': { opacity: 0.85 }
}}
/>
)}
</ListItem>
))}
</List>
{/* Full-size image preview dialog */}
<Dialog
open={!!expandedImage}
onClose={() => setExpandedImage(null)}
maxWidth="lg"
>
{expandedImage && (
<Box
component="img"
src={expandedImage}
onClick={() => setExpandedImage(null)}
sx={{ maxWidth: '90vw', maxHeight: '90vh', cursor: 'pointer' }}
/>
)}
</Dialog>
</Box>
);
}
export default DocumentList;

View File

@ -0,0 +1,151 @@
import React, { useRef, useState } from 'react';
import {
Box,
Button,
Chip,
LinearProgress,
Typography
} from '@mui/material';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
const ALLOWED_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf'
];
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
export interface UploadedDocument {
id: number;
originalName: string;
contentType: string;
sizeBytes: number;
createdAt: string;
}
interface FileUploadButtonProps {
scope: number; // 0 = TicketAttachment, 1 = InstallationDocument
ticketId?: number;
ticketCommentId?: number;
installationId?: number;
onUploaded?: (doc: UploadedDocument) => void;
disabled?: boolean;
}
function FileUploadButton({
scope,
ticketId,
ticketCommentId,
installationId,
onUploaded,
disabled = false
}: FileUploadButtonProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState('');
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const validFiles: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!ALLOWED_TYPES.includes(file.type)) {
setError(`Invalid file type: ${file.name}`);
return;
}
if (file.size > MAX_FILE_SIZE) {
setError(`File too large: ${file.name} (max 25 MB)`);
return;
}
validFiles.push(file);
}
setError('');
setPendingFiles((prev) => [...prev, ...validFiles]);
// Reset input so the same file can be selected again
if (inputRef.current) inputRef.current.value = '';
// Upload files sequentially to avoid race conditions
uploadFilesSequentially(validFiles);
};
const uploadFilesSequentially = async (files: File[]) => {
setUploading(true);
for (const file of files) {
setProgress(0);
const formData = new FormData();
formData.append('file', file);
try {
const res = await axiosConfig.post('/UploadDocument', formData, {
params: { scope, ticketId, ticketCommentId, installationId },
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total) setProgress(Math.round((e.loaded * 100) / e.total));
}
});
setPendingFiles((prev) => prev.filter((f) => f !== file));
if (onUploaded) onUploaded(res.data);
} catch (err: any) {
const serverMsg = err?.response?.data || err?.message || 'Unknown error';
setError(`Failed to upload ${file.name}: ${serverMsg}`);
setPendingFiles((prev) => prev.filter((f) => f !== file));
}
}
setUploading(false);
setProgress(0);
};
return (
<Box>
<input
ref={inputRef}
type="file"
accept={ALLOWED_TYPES.join(',')}
multiple
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Button
variant="outlined"
size="small"
startIcon={<AttachFileIcon />}
onClick={() => inputRef.current?.click()}
disabled={disabled || uploading}
>
<FormattedMessage id="attachFiles" defaultMessage="Attach Files" />
</Button>
{uploading && (
<Box sx={{ mt: 1, width: '100%' }}>
<LinearProgress variant="determinate" value={progress} />
</Box>
)}
{pendingFiles.length > 0 && (
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{pendingFiles.map((f, i) => (
<Chip key={i} label={f.name} size="small" variant="outlined" />
))}
</Box>
)}
{error && (
<Typography color="error" variant="caption" sx={{ mt: 0.5, display: 'block' }}>
{error}
</Typography>
)}
</Box>
);
}
export default FileUploadButton;

View File

@ -18,7 +18,7 @@ function Footer() {
> >
<Box> <Box>
<Typography variant="subtitle1"> <Typography variant="subtitle1">
&copy; 2025 - Inesco Energy AG &copy; 2025 - inesco energy AG
</Typography> </Typography>
</Box> </Box>
<Typography <Typography
@ -33,7 +33,7 @@ function Footer() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
Inesco Energy AG inesco energy AG
</Link> </Link>
</Typography> </Typography>
</Box> </Box>

View File

@ -98,7 +98,7 @@ function Logo() {
const theme = useTheme(); const theme = useTheme();
return ( return (
<TooltipWrapper title="inesco Energy" arrow> <TooltipWrapper title="inesco energy" arrow>
<LogoWrapper to="/overview"> <LogoWrapper to="/overview">
<Badge <Badge
sx={{ sx={{

View File

@ -42,7 +42,8 @@ function Login() {
setAccessToSalidomo, setAccessToSalidomo,
setAccessToSodiohome, setAccessToSodiohome,
setAccessToSodistore, setAccessToSodistore,
setAccessToSodistoreGrid setAccessToSodistoreGrid,
setAccessToSodistorePro
} = useContext(ProductIdContext); } = useContext(ProductIdContext);
const navigate = useNavigate(); const navigate = useNavigate();
@ -86,16 +87,19 @@ function Login() {
setAccessToSodiohome(response.data.accessToSodioHome); setAccessToSodiohome(response.data.accessToSodioHome);
setAccessToSodistore(response.data.accessToSodistoreMax); setAccessToSodistore(response.data.accessToSodistoreMax);
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid); setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
if (response.data.accessToSalimax) { setAccessToSodistorePro(response.data.accessToSodistorePro);
navigate(routes.installations); if (response.data.accessToSodioHome) {
} else if (response.data.accessToSalidomo) { navigate(routes.sodiohome_installations);
navigate(routes.salidomo_installations); } else if (response.data.accessToSodistorePro) {
} else if (response.data.accessToSodistoreMax) { navigate(routes.sodistorepro_installations);
navigate(routes.sodistore_installations);
} else if (response.data.accessToSodistoreGrid) { } else if (response.data.accessToSodistoreGrid) {
navigate(routes.sodistoregrid_installations); navigate(routes.sodistoregrid_installations);
} else if (response.data.accessToSodistoreMax) {
navigate(routes.sodistore_installations);
} else if (response.data.accessToSalimax) {
navigate(routes.installations);
} else { } else {
navigate(routes.sodiohome_installations); navigate(routes.salidomo_installations);
} }
} }
}) })

View File

@ -1,117 +1,85 @@
import { Step } from 'react-joyride'; import { Step } from 'react-joyride';
import { IntlShape } from 'react-intl'; import { IntlShape } from 'react-intl';
// --- Build a single step with i18n --- // --- Tab key → i18n content description mapping ---
// Only the *content* (description) needs i18n keys.
// The *title* is read directly from the rendered tab element's text,
// so it always matches the tab label in the current language.
function makeStep( const tabContentKey: Record<string, string> = {
intl: IntlShape, list: 'tourListContent',
target: string, tree: 'tourTreeContent',
titleId: string, live: 'tourLiveContent',
contentId: string, overview: 'tourOverviewContent',
placement: Step['placement'] = 'bottom', batteryview: 'tourBatteryviewContent',
disableBeacon = false pvview: 'tourPvviewContent',
): Step { log: 'tourLogContent',
return { information: 'tourInformationContent',
target, report: 'tourReportContent',
title: intl.formatMessage({ id: titleId }), manage: 'tourManageContent',
content: intl.formatMessage({ id: contentId }), configuration: 'tourConfigurationContent',
placement, history: 'tourHistoryContent',
...(disableBeacon ? { disableBeacon: true } : {}) installationTickets: 'tourInstallationTicketsContent'
};
}
// --- 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 dynamically from the DOM.
* Scans for all rendered `#tour-tab-*` elements and creates a step for each.
// --- Build tour steps from tab value list --- * The step title is the tab's own rendered text (matching across languages).
*/
function buildTourSteps(intl: IntlShape, tabValues: string[], includeInstallationHint = false, isInsideInstallation = false): Step[] { export function buildDynamicTourSteps(intl: IntlShape, isInsideInstallation: boolean): Step[] {
const steps: Step[] = []; const steps: Step[] = [];
// Language selector step (only on list/tree pages, not inside an installation)
if (!isInsideInstallation) { if (!isInsideInstallation) {
steps.push(makeStep(intl, '[data-tour="language-selector"]', 'tourLanguageTitle', 'tourLanguageContent', 'bottom', true)); const langEl = document.querySelector('[data-tour="language-selector"]');
} if (langEl) {
for (const value of tabValues) { steps.push({
if (isInsideInstallation && listPageOnlyTabs.has(value)) continue; target: '[data-tour="language-selector"]',
const cfg = tabConfig[value]; title: intl.formatMessage({ id: 'tourLanguageTitle' }),
if (cfg) { content: intl.formatMessage({ id: 'tourLanguageContent' }),
steps.push(makeStep(intl, `#tour-tab-${value}`, cfg.titleId, cfg.contentId, 'bottom', steps.length === 0)); placement: 'bottom',
disableBeacon: true
});
} }
} }
if (includeInstallationHint && !isInsideInstallation) {
steps.push(makeStep(intl, '#tour-tab-list', 'tourExploreTitle', 'tourExploreContent')); // Collect all tour-tab elements in DOM order
const tabEls = document.querySelectorAll<HTMLElement>('[id^="tour-tab-"]');
tabEls.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const tabValue = el.id.replace('tour-tab-', '');
// Skip list/tree tabs when inside an installation (already covered on list page)
if (isInsideInstallation && (tabValue === 'list' || tabValue === 'tree')) return;
// Use the tab's own rendered text as title (matches current language)
const title = el.textContent?.trim() || tabValue;
const contentKey = tabContentKey[tabValue];
const content = contentKey
? intl.formatMessage({ id: contentKey })
: '';
steps.push({
target: `#tour-tab-${tabValue}`,
title,
content,
placement: 'bottom' as const,
...(steps.length === 0 ? { disableBeacon: true } : {})
});
});
// "Explore" hint at the end when on list/tree page
if (!isInsideInstallation && document.querySelector('#tour-tab-list')) {
steps.push({
target: '#tour-tab-list',
title: intl.formatMessage({ id: 'tourExploreTitle' }),
content: intl.formatMessage({ id: 'tourExploreContent' }),
placement: 'bottom'
});
} }
return steps; 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

@ -56,7 +56,6 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
Current: device?.[`Battery${batteryIndex}PackTotalCurrent`] ?? 0, Current: device?.[`Battery${batteryIndex}PackTotalCurrent`] ?? 0,
Power: device?.[`Battery${batteryIndex}Power`] ?? 0, Power: device?.[`Battery${batteryIndex}Power`] ?? 0,
Soc: device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0, Soc: device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0,
Soh: device?.[`Battery${batteryIndex}Soh`] ?? 0,
} }
}; };
} else { } else {
@ -69,7 +68,6 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
Current: inverter[`Battery${index}Current`] ?? 0, Current: inverter[`Battery${index}Current`] ?? 0,
Power: inverter[`Battery${index}Power`] ?? 0, Power: inverter[`Battery${index}Power`] ?? 0,
Soc: inverter[`Battery${index}Soc`] ?? 0, Soc: inverter[`Battery${index}Soc`] ?? 0,
Soh: inverter[`Battery${index}Soh`] ?? 0,
} }
}; };
} }
@ -221,7 +219,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
<TableCell align="center"><FormattedMessage id="batteryVoltage" defaultMessage="Battery Voltage" /></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="current" defaultMessage="Current" /></TableCell>
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell> <TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
<TableCell align="center"><FormattedMessage id="soh" defaultMessage="SoH" /></TableCell> {/*<TableCell align="center"><FormattedMessage id="soh" defaultMessage="SoH" /></TableCell>*/}
{/*<TableCell align="center">Daily Charge Energy</TableCell>*/} {/*<TableCell align="center">Daily Charge Energy</TableCell>*/}
{/*<TableCell align="center">Daily Discharge Energy</TableCell>*/} {/*<TableCell align="center">Daily Discharge Energy</TableCell>*/}
</TableRow> </TableRow>
@ -240,12 +238,13 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
align="center" align="center"
sx={{ fontWeight: 'bold' }} sx={{ fontWeight: 'bold' }}
> >
<Link {/* Detailed battery view commented out */}
{/*<Link
style={{ color: 'black' }} style={{ color: 'black' }}
to={routes.detailed_view + BatteryId} to={routes.detailed_view + BatteryId}
> >*/}
{'Battery Cluster ' + BatteryId} {'Battery Cluster ' + BatteryId}
</Link> {/*</Link>*/}
</TableCell> </TableCell>
<TableCell <TableCell
sx={{ sx={{
@ -293,7 +292,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
> >
{battery.Soc + ' %'} {battery.Soc + ' %'}
</TableCell> </TableCell>
<TableCell {/*<TableCell
sx={{ sx={{
width: '8%', width: '8%',
textAlign: 'center', textAlign: 'center',
@ -307,7 +306,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
}} }}
> >
{battery.Soh + ' %'} {battery.Soh + ' %'}
</TableCell> </TableCell>*/}
{/*<TableCell*/} {/*<TableCell*/}
{/* sx={{*/} {/* sx={{*/}
{/* width: '15%',*/} {/* width: '15%',*/}

View File

@ -151,22 +151,25 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
pathsToSearch.push('Node' + i); pathsToSearch.push('Node' + i);
} }
const total = pathsToSearch.length;
let i = 0; let i = 0;
pathsToSearch.forEach((devicePath) => { pathsToSearch.forEach((devicePath) => {
if ( if (
Object.hasOwnProperty.call(chartData[category].data, devicePath) && Object.hasOwnProperty.call(chartData[category].data, devicePath) &&
chartData[category].data[devicePath].data.length != 0 chartData[category].data[devicePath].data.length != 0
) { ) {
// Spread color indices evenly across the palette for better contrast
const colorIndex = total <= 1 ? 0 : Math.round(i * 9 / (total - 1));
series.push({ series.push({
...chartData[category].data[devicePath], ...chartData[category].data[devicePath],
color: color:
color === 'blue' color === 'blue'
? blueColors[i] ? blueColors[colorIndex]
: color === 'red' : color === 'red'
? redColors[i] ? redColors[colorIndex]
: color === 'green' : color === 'green'
? greenColors[i] ? greenColors[colorIndex]
: orangeColors[i] : orangeColors[colorIndex]
}); });
} }
i++; i++;
@ -751,63 +754,7 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
</Card> </Card>
</Grid> </Grid>
{/* Battery SoH Chart */} {/* Battery SoH Chart — removed for SodiostoreHome */}
<Grid item md={12} xs={12}>
<Card
sx={{
overflow: 'visible',
marginTop: '10px',
marginBottom: '30px'
}}
>
<Box sx={{ marginLeft: '20px' }}>
<Box display="flex" alignItems="center">
<Box>
<Typography variant="subtitle1" noWrap>
<FormattedMessage
id="battery_soh"
defaultMessage="Battery SOH (State Of Health)"
/>
</Typography>
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
pt: 3
}}
></Box>
</Box>
<ReactApexChart
options={{
...getChartOptions(
batteryViewDataArray[chartState].chartOverview.Soh,
'daily',
[],
true
),
chart: {
events: {
beforeZoom: (chartContext, options) => {
startZoom();
handleBeforeZoom(chartContext, options);
}
}
}
}}
series={generateSeries(
batteryViewDataArray[chartState].chartData,
'Soh',
'green'
)}
type="line"
height={420}
/>
</Card>
</Grid>
</Grid> </Grid>
</> </>
)} )}

View File

@ -0,0 +1,63 @@
import React, { useContext, useState } from 'react';
import {
Box,
Card,
CardContent,
CardHeader,
Container,
Divider,
Typography
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes';
import FileUploadButton from 'src/components/FileUploadButton';
import DocumentList from 'src/components/DocumentList';
interface DocumentsTabProps {
installationId: number;
}
function DocumentsTab({ installationId }: DocumentsTabProps) {
const { currentUser } = useContext(UserContext);
const [refreshKey, setRefreshKey] = useState(0);
const canDelete = currentUser?.userType === UserType.admin;
return (
<Container maxWidth="lg" sx={{ mt: 3, mb: 3 }}>
<Card>
<CardHeader
title={
<FormattedMessage
id="installationDocuments"
defaultMessage="Installation Documents"
/>
}
/>
<Divider />
<CardContent>
<DocumentList
installationId={installationId}
refreshKey={refreshKey}
canDelete={canDelete}
/>
<Box sx={{ mt: 2 }}>
<FileUploadButton
scope={1}
installationId={installationId}
onUploaded={() => setRefreshKey((k) => k + 1)}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
<FormattedMessage
id="documentsHint"
defaultMessage="Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB."
/>
</Typography>
</CardContent>
</Card>
</Container>
);
}
export default DocumentsTab;

View File

@ -57,7 +57,7 @@ function Information(props: InformationProps) {
const canEdit = currentUser.userType == UserType.admin; const canEdit = currentUser.userType == UserType.admin;
const isPartner = currentUser.userType == UserType.partner; const isPartner = currentUser.userType == UserType.partner;
const isSodistore = formValues.product === 3 || formValues.product === 4; const isSodistore = formValues.product === 3 || formValues.product === 4 || formValues.product === 5;
const [networkProviders, setNetworkProviders] = useState<string[]>([]); const [networkProviders, setNetworkProviders] = useState<string[]>([]);
const [loadingProviders, setLoadingProviders] = useState(false); const [loadingProviders, setLoadingProviders] = useState(false);
@ -426,12 +426,18 @@ function Information(props: InformationProps) {
label="S3 Bucket Name" label="S3 Bucket Name"
name="s3bucketname" name="s3bucketname"
value={ value={
formValues.product === 0 || formValues.product == 3 formValues.product === 0 || formValues.product === 3
? formValues.s3BucketId + ? formValues.s3BucketId +
'-3e5b3069-214a-43ee-8d85-57d72000c19d' '-3e5b3069-214a-43ee-8d85-57d72000c19d'
: formValues.product == 4 : formValues.product === 4
? formValues.s3BucketId + ? formValues.s3BucketId +
'-5109c126-e141-43ab-8658-f3c44c838ae8' '-5109c126-e141-43ab-8658-f3c44c838ae8'
: formValues.product === 5
? formValues.s3BucketId +
'-325c9373-9025-4a8d-bf5a-f9eedf1f155c'
: formValues.product === 1
? formValues.s3BucketId +
'-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e'
: formValues.s3BucketId + : formValues.s3BucketId +
'-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa' '-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa'
} }

View File

@ -34,7 +34,7 @@ import { useNavigate } from 'react-router-dom';
import { UserType } from '../../../interfaces/UserTypes'; import { UserType } from '../../../interfaces/UserTypes';
import axiosConfig from '../../../Resources/axiosConfig'; import axiosConfig from '../../../Resources/axiosConfig';
import { import {
INSTALLATION_PRESETS, getPresetsForDevice,
PresetConfig, PresetConfig,
BatterySnTree, BatterySnTree,
parseBatterySnTree, parseBatterySnTree,
@ -42,6 +42,8 @@ import {
remapTree, remapTree,
computeFlatValues, computeFlatValues,
wouldLoseData, wouldLoseData,
SODIOHOME_DEVICE_TYPES,
buildSodistoreProPreset,
} from './installationSetupUtils'; } from './installationSetupUtils';
interface InformationSodistorehomeProps { interface InformationSodistorehomeProps {
@ -60,7 +62,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
const theme = useTheme(); const theme = useTheme();
const intl = useIntl(); const intl = useIntl();
const [formValues, setFormValues] = useState(props.values); const [formValues, setFormValues] = useState(props.values);
const requiredFields = ['name', 'region', 'location', 'country']; const requiredFields = ['name'];
const [openModalDeleteInstallation, setOpenModalDeleteInstallation] = const [openModalDeleteInstallation, setOpenModalDeleteInstallation] =
useState(false); useState(false);
const [pendingPreset, setPendingPreset] = useState<string | null>(null); const [pendingPreset, setPendingPreset] = useState<string | null>(null);
@ -93,16 +95,25 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
return [value.trim()]; return [value.trim()];
}; };
const DeviceTypes = [ const isSodistorePro = props.values.product === 5;
{ id: 3, name: 'Growatt' }, const DeviceTypes = isSodistorePro
{ id: 4, name: 'Sinexcel' } ? [{ id: 4, name: 'inesco 12K - WR Hybrid' } as const]
]; : SODIOHOME_DEVICE_TYPES;
// Preset state — initializes from persisted installationModel, empty for legacy // Preset state — initializes from persisted installationModel, empty for legacy
const [selectedPreset, setSelectedPreset] = useState<string>( const [selectedPreset, setSelectedPreset] = useState<string>(
props.values.installationModel || '' props.values.installationModel || ''
); );
const presetConfig: PresetConfig | null = INSTALLATION_PRESETS[selectedPreset] || null; const [inverterCount, setInverterCount] = useState<string>(
isSodistorePro && props.values.installationModel
? props.values.installationModel
: ''
);
const presetConfig: PresetConfig | null = isSodistorePro
? (inverterCount && parseInt(inverterCount, 10) > 0
? buildSodistoreProPreset(parseInt(inverterCount, 10))
: null)
: (getPresetsForDevice(formValues.device)[selectedPreset] || null);
const [batterySnTree, setBatterySnTree] = useState<BatterySnTree>(() => { const [batterySnTree, setBatterySnTree] = useState<BatterySnTree>(() => {
if (presetConfig) { if (presetConfig) {
@ -130,8 +141,32 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
return Array.from({ length: invCount }, () => '1'); return Array.from({ length: invCount }, () => '1');
}); });
// When presetConfig is available, ensure flat values (batteryClusterNumber, batteryNumber)
// stay in sync with the current preset structure. This handles:
// - Legacy installations with device=0 → user sets device type
// - Preset structure changed (e.g., Growatt home 9 was [[1,1]] → now [[2]])
useEffect(() => {
if (!presetConfig) return;
// Re-parse battery tree if empty but serial numbers exist
let tree = batterySnTree;
if (tree.length === 0 && props.values.batterySerialNumbers) {
tree = parseBatterySnTree(props.values.batterySerialNumbers, presetConfig);
setBatterySnTree(tree);
}
// Always recalculate flat values from current preset to keep DB in sync
const flat = computeFlatValues(presetConfig, tree);
if (
flat.batteryClusterNumber !== formValues.batteryClusterNumber ||
flat.batteryNumber !== formValues.batteryNumber
) {
setFormValues((prev) => ({ ...prev, ...flat }));
}
}, [presetConfig]);
const handlePresetChange = (newPreset: string) => { const handlePresetChange = (newPreset: string) => {
const newConfig = INSTALLATION_PRESETS[newPreset]; const newConfig = getPresetsForDevice(formValues.device)[newPreset];
if (!newConfig) return; if (!newConfig) return;
// Check for data loss — either from existing tree or legacy flat data // Check for data loss — either from existing tree or legacy flat data
@ -160,7 +195,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
}; };
const applyPreset = (newPreset: string) => { const applyPreset = (newPreset: string) => {
const newConfig = INSTALLATION_PRESETS[newPreset]; const newConfig = getPresetsForDevice(formValues.device)[newPreset];
if (!newConfig) return; if (!newConfig) return;
setSelectedPreset(newPreset); setSelectedPreset(newPreset);
@ -202,6 +237,38 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
}); });
}; };
const handleInverterCountChange = (value: string) => {
if (value !== '' && !/^\d+$/.test(value)) return;
if (value !== '' && parseInt(value, 10) > 20) return;
setInverterCount(value);
const count = parseInt(value, 10);
if (isNaN(count) || count < 1) {
setBatterySnTree([]);
setFormValues({ ...formValues, installationModel: value });
return;
}
const newConfig = buildSodistoreProPreset(count);
const newTree = batterySnTree.length > 0
? remapTree(batterySnTree, newConfig)
: buildEmptyTree(newConfig);
setBatterySnTree(newTree);
const newInvSNs = Array.from({ length: count }, (_, i) => inverterSerialNumbers[i] || '');
const newDlSNs = Array.from({ length: count }, (_, i) => dataloggerSerialNumbers[i] || '');
const newPvStrings = Array.from({ length: count }, (_, i) => pvStringsPerInverter[i] || '1');
setInverterSerialNumbers(newInvSNs);
setDataloggerSerialNumbers(newDlSNs);
setPvStringsPerInverter(newPvStrings);
const flat = computeFlatValues(newConfig, newTree);
setFormValues({
...formValues,
...flat,
installationModel: value,
inverterSN: newInvSNs.join('/'),
dataloggerSN: newDlSNs.join('/'),
pvStringsPerInverter: newPvStrings.join(','),
});
};
const handleInverterSnChange = (invIdx: number, value: string) => { const handleInverterSnChange = (invIdx: number, value: string) => {
const updated = [...inverterSerialNumbers]; const updated = [...inverterSerialNumbers];
updated[invIdx] = value; updated[invIdx] = value;
@ -272,10 +339,28 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
const handleChange = (e) => { const handleChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
setFormValues({ const updated = { ...formValues, [name]: value };
...formValues,
[name]: value // When device type changes, reset preset if it's not available for the new device
}); if (name === 'device' && !isSodistorePro) {
const newDevicePresets = getPresetsForDevice(Number(value));
if (selectedPreset && !newDevicePresets[selectedPreset]) {
setSelectedPreset('');
setBatterySnTree([]);
setInverterSerialNumbers([]);
setDataloggerSerialNumbers([]);
setPvStringsPerInverter([]);
updated.installationModel = '';
updated.batteryNumber = 0;
updated.batteryClusterNumber = 0;
updated.batterySerialNumbers = '';
updated.inverterSN = '';
updated.dataloggerSN = '';
updated.pvStringsPerInverter = '';
}
}
setFormValues(updated);
}; };
const handleSubmit = () => { const handleSubmit = () => {
@ -533,27 +618,45 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</div> </div>
<div> <div>
<TextField <TextField
label={<FormattedMessage id="region" defaultMessage="Region" />} label={<FormattedMessage id="street" defaultMessage="Street" />}
name="region" name="street"
value={formValues.region} value={formValues.street || ''}
onChange={handleChange} onChange={handleChange}
variant="outlined" variant="outlined"
fullWidth fullWidth
required={canEdit}
error={canEdit && formValues.region === ''}
inputProps={{ readOnly: !canEdit }} inputProps={{ readOnly: !canEdit }}
/> />
</div> </div>
<div> <div>
<TextField <TextField
label={<FormattedMessage id="location" defaultMessage="Location" />} label={<FormattedMessage id="postCode" defaultMessage="Postcode" />}
name="location" name="postCode"
value={formValues.location} value={formValues.postCode || ''}
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
<TextField
label={<FormattedMessage id="city" defaultMessage="City" />}
name="city"
value={formValues.city || ''}
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
<TextField
label={<FormattedMessage id="canton" defaultMessage="Canton" />}
name="canton"
value={formValues.canton || ''}
onChange={handleChange} onChange={handleChange}
variant="outlined" variant="outlined"
fullWidth fullWidth
required={canEdit}
error={canEdit && formValues.location === ''}
inputProps={{ readOnly: !canEdit }} inputProps={{ readOnly: !canEdit }}
/> />
</div> </div>
@ -561,12 +664,21 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
<TextField <TextField
label={<FormattedMessage id="country" defaultMessage="Country" />} label={<FormattedMessage id="country" defaultMessage="Country" />}
name="country" name="country"
value={formValues.country} value={formValues.country || ''}
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
<TextField
label={<FormattedMessage id="distributionPartner" defaultMessage="Distribution Partner" />}
name="distributionPartner"
value={formValues.distributionPartner || ''}
onChange={handleChange} onChange={handleChange}
variant="outlined" variant="outlined"
fullWidth fullWidth
required={canEdit}
error={canEdit && formValues.country === ''}
inputProps={{ readOnly: !canEdit }} inputProps={{ readOnly: !canEdit }}
/> />
</div> </div>
@ -583,6 +695,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</div> </div>
)} )}
{!isSodistorePro && (
<div> <div>
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}> <FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}> <InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
@ -602,6 +715,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</Select> </Select>
</FormControl> </FormControl>
</div> </div>
)}
<div> <div>
<Autocomplete <Autocomplete
@ -614,7 +728,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
onInputChange={(_e, val) => onInputChange={(_e, val) =>
setFormValues({ ...formValues, networkProvider: val || '' }) setFormValues({ ...formValues, networkProvider: val || '' })
} }
disabled={!canEdit && !isPartner} disabled={!canEdit}
loading={loadingProviders} loading={loadingProviders}
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
@ -636,6 +750,18 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
/> />
</div> </div>
<div>
<TextField
label={<FormattedMessage id="emailAddress" defaultMessage="Email Address" />}
name="email"
value={formValues.email || ''}
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div> <div>
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}> <FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}> <InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
@ -652,7 +778,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
const val = e.target.value as string; const val = e.target.value as string;
setFormValues({ ...formValues, externalEms: val }); setFormValues({ ...formValues, externalEms: val });
}} }}
inputProps={{ readOnly: !canEdit && !isPartner }} inputProps={{ readOnly: !canEdit }}
> >
<MenuItem value="No"><FormattedMessage id="emsNo" defaultMessage="No" /></MenuItem> <MenuItem value="No"><FormattedMessage id="emsNo" defaultMessage="No" /></MenuItem>
<MenuItem value="Solar Manager">Solar Manager</MenuItem> <MenuItem value="Solar Manager">Solar Manager</MenuItem>
@ -673,7 +799,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
onChange={handleChange} onChange={handleChange}
variant="outlined" variant="outlined"
fullWidth fullWidth
inputProps={{ readOnly: !canEdit && !isPartner }} inputProps={{ readOnly: !canEdit }}
/> />
</div> </div>
)} )}
@ -696,13 +822,64 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
<FormattedMessage id="installationSetup" defaultMessage="Installation Setup" /> <FormattedMessage id="installationSetup" defaultMessage="Installation Setup" />
</Typography> </Typography>
<div>
<TextField
label={<FormattedMessage id="installationSerialNumber" defaultMessage="Installation Serial Number" />}
name="serialNumber"
value={formValues.serialNumber || ''}
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div> <div>
<FormControl sx={{ m: 1, width: '50ch' }}> <FormControl sx={{ m: 1, width: '50ch' }}>
<InputLabel <InputLabel
shrink shrink
sx={{ fontSize: 14, backgroundColor: 'white', px: 0.5 }} sx={{ fontSize: 14, backgroundColor: 'white', px: 0.5 }}
> >
Installation Model <FormattedMessage id="couplingType" defaultMessage="AC/DC Coupling" />
</InputLabel>
<Select
name="couplingType"
value={formValues.couplingType || 'DC'}
onChange={handleChange}
inputProps={{ readOnly: !canEdit }}
displayEmpty
notched
>
<MenuItem value="AC">
<FormattedMessage id="couplingAC" defaultMessage="AC-coupled" />
</MenuItem>
<MenuItem value="DC">
<FormattedMessage id="couplingDC" defaultMessage="DC-coupled" />
</MenuItem>
</Select>
</FormControl>
</div>
{isSodistorePro ? (
<div>
<TextField
label={<FormattedMessage id="numberOfInverters" defaultMessage="Number of Inverters" />}
type="text"
value={inverterCount}
onChange={(e) => handleInverterCountChange(e.target.value)}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
) : (
<div>
<FormControl sx={{ m: 1, width: '50ch' }}>
<InputLabel
shrink
sx={{ fontSize: 14, backgroundColor: 'white', px: 0.5 }}
>
<FormattedMessage id="installationModel" defaultMessage="Installation Model" />
</InputLabel> </InputLabel>
<Select <Select
value={selectedPreset} value={selectedPreset}
@ -714,7 +891,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
<MenuItem value="" disabled> <MenuItem value="" disabled>
<em><FormattedMessage id="selectModel" defaultMessage="Select model..." /></em> <em><FormattedMessage id="selectModel" defaultMessage="Select model..." /></em>
</MenuItem> </MenuItem>
{Object.keys(INSTALLATION_PRESETS).map((name) => ( {Object.keys(getPresetsForDevice(formValues.device)).map((name) => (
<MenuItem key={name} value={name}> <MenuItem key={name} value={name}>
{name} {name}
</MenuItem> </MenuItem>
@ -722,12 +899,25 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</Select> </Select>
</FormControl> </FormControl>
</div> </div>
)}
<div> <div>
<TextField <TextField
label={<FormattedMessage id="installationSerialNumber" defaultMessage="Installation Serial Number" />} label={<FormattedMessage id="inverterFirmwareVersion" defaultMessage="Inverter Firmware Version" />}
name="serialNumber" name="inverterFirmwareVersion"
value={formValues.serialNumber} value={formValues.inverterFirmwareVersion || ''}
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
<TextField
label={<FormattedMessage id="batteryFirmwareVersion" defaultMessage="Battery Firmware Version" />}
name="batteryFirmwareVersion"
value={formValues.batteryFirmwareVersion || ''}
onChange={handleChange} onChange={handleChange}
variant="outlined" variant="outlined"
fullWidth fullWidth
@ -746,7 +936,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
return ( return (
<Accordion <Accordion
key={`inv-${invIdx}`} key={`inv-${invIdx}`}
defaultExpanded={!invSn} defaultExpanded={false}
sx={{ mt: 1 }} sx={{ mt: 1 }}
> >
<AccordionSummary <AccordionSummary
@ -802,7 +992,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
return ( return (
<Accordion <Accordion
key={`cl-${invIdx}-${clIdx}`} key={`cl-${invIdx}-${clIdx}`}
defaultExpanded={true} defaultExpanded={false}
sx={{ mt: 1 }} sx={{ mt: 1 }}
> >
<AccordionSummary <AccordionSummary
@ -870,7 +1060,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
<div> <div>
<TextField <TextField
label="S3 Bucket Name" label="S3 Bucket Name"
value={formValues.s3BucketId + '-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa'} value={formValues.s3BucketId + '-' + (isSodistorePro ? '325c9373-9025-4a8d-bf5a-f9eedf1f155c' : 'e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa')}
variant="outlined" variant="outlined"
fullWidth fullWidth
/> />

View File

@ -1,14 +1,41 @@
export const SODIOHOME_DEVICE_TYPES = [
{ id: 3, name: 'Growatt' },
{ id: 4, name: 'inesco 12K - WR Hybrid' }
] as const;
export const getDeviceTypeName = (deviceId: number): string =>
SODIOHOME_DEVICE_TYPES.find(d => d.id === deviceId)?.name ?? '';
// [inverter][cluster] = batteryCount // [inverter][cluster] = batteryCount
export type PresetConfig = number[][]; export type PresetConfig = number[][];
// 3D array: [inverter][cluster][batteryIndex] = serialNumber // 3D array: [inverter][cluster][batteryIndex] = serialNumber
export type BatterySnTree = string[][][]; export type BatterySnTree = string[][][];
export const INSTALLATION_PRESETS: Record<string, PresetConfig> = { // Device-aware presets: keyed by device ID, then model name
// Device 3 = Growatt, Device 4 = inesco 12K
export const INSTALLATION_PRESETS: Record<number, Record<string, PresetConfig>> = {
3: {
'sodistore home 9': [[2]],
'sodistore home 18': [[4]],
},
4: {
'sodistore home 9': [[1, 1]], 'sodistore home 9': [[1, 1]],
'sodistore home 18': [[2, 2]], 'sodistore home 18': [[2, 2]],
'sodistore home 27': [[2, 2], [1, 1]], 'sodistore home 27': [[2, 2], [1, 1]],
'sodistore home 36': [[2, 2], [2, 2]], 'sodistore home 36': [[2, 2], [2, 2]],
},
};
export const getPresetsForDevice = (deviceId: number): Record<string, PresetConfig> =>
INSTALLATION_PRESETS[deviceId] ?? {};
export const buildSodistoreProPreset = (inverterCount: number): PresetConfig =>
Array.from({ length: inverterCount }, () => [2, 2]);
export const parseSodistoreProInverterCount = (model: string): number => {
const n = parseInt(model, 10);
return isNaN(n) || n < 1 ? 1 : n;
}; };
export const buildEmptyTree = (preset: PresetConfig): BatterySnTree => { export const buildEmptyTree = (preset: PresetConfig): BatterySnTree => {

View File

@ -29,6 +29,7 @@ import BatteryView from '../BatteryView/BatteryView';
import Configuration from '../Configuration/Configuration'; import Configuration from '../Configuration/Configuration';
import PvView from '../PvView/PvView'; import PvView from '../PvView/PvView';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
interface singleInstallationProps { interface singleInstallationProps {
current_installation?: I_Installation; current_installation?: I_Installation;
@ -150,7 +151,8 @@ function Installation(props: singleInstallationProps) {
return false; return false;
} }
console.log(`Timestamp: ${timestamp}`); console.log(`Timestamp: ${timestamp}`);
console.log(res[timestamp]); const { Config: { S3: { Key, Secret, ...s3Rest } = {} as any, ...configRest } = {} as any, ...dataRest } = res[timestamp] || {};
console.log({ ...dataRest, Config: { ...configRest, S3: { ...s3Rest, Key: '***', Secret: '***' } } });
setValues(res[timestamp]); setValues(res[timestamp]);
await timeout(2000); await timeout(2000);
@ -381,7 +383,7 @@ function Installation(props: singleInstallationProps) {
{loading && {loading &&
currentTab != 'information' && currentTab != 'information' &&
currentTab != 'history' && currentTab != 'history' &&
currentTab != 'manage' && // currentTab != 'manage' &&
currentTab != 'log' && currentTab != 'log' &&
currentTab != 'installationTickets' && ( currentTab != 'installationTickets' && (
<Container <Container
@ -538,7 +540,7 @@ function Installation(props: singleInstallationProps) {
/> />
)} )}
{currentUser.userType == UserType.admin && ( {/* {currentUser.userType == UserType.admin && (
<Route <Route
path={routes.manage} path={routes.manage}
element={ element={
@ -550,7 +552,7 @@ function Installation(props: singleInstallationProps) {
</AccessContextProvider> </AccessContextProvider>
} }
/> />
)} )} */}
{currentUser.userType == UserType.admin && ( {currentUser.userType == UserType.admin && (
<Route <Route
@ -563,6 +565,17 @@ function Installation(props: singleInstallationProps) {
/> />
)} )}
{(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && (
<Route
path={routes.documents}
element={
<DocumentsTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route <Route
path={'*'} path={'*'}
element={<Navigate to={routes.live}></Navigate>} element={<Navigate to={routes.live}></Navigate>}

View File

@ -131,7 +131,7 @@ export const fetchAggregatedDataJson = (
} else if (r.status === 200) { } else if (r.status === 200) {
const jsontext = await r.text(); const jsontext = await r.text();
if (product === 2) { if (product === 2 || product === 5) {
return parseSinexcelAggregatedData(jsontext); return parseSinexcelAggregatedData(jsontext);
} }

View File

@ -26,14 +26,15 @@ function InstallationTabs(props: InstallationTabsProps) {
const tabList = [ const tabList = [
'live', 'live',
'overview', 'overview',
'manage', // 'manage',
'batteryview', 'batteryview',
'log', 'log',
'information', 'information',
'configuration', 'configuration',
'history', 'history',
'pvview', 'pvview',
'installationTickets' 'installationTickets',
'documents'
]; ];
const [currentTab, setCurrentTab] = useState<string>(undefined); const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -125,15 +126,15 @@ function InstallationTabs(props: InstallationTabsProps) {
) )
}, },
{ // {
value: 'manage', // value: 'manage',
label: ( // label: (
<FormattedMessage // <FormattedMessage
id="manage" // id="manage"
defaultMessage="Access Management" // defaultMessage="Access Management"
/> // />
) // )
}, // },
{ {
value: 'log', value: 'log',
label: <FormattedMessage id="log" defaultMessage="Log" /> label: <FormattedMessage id="log" defaultMessage="Log" />
@ -170,6 +171,10 @@ function InstallationTabs(props: InstallationTabsProps) {
{ {
value: 'installationTickets', value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" /> label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
},
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
} }
] ]
: currentUser.userType == UserType.partner : currentUser.userType == UserType.partner
@ -201,6 +206,10 @@ function InstallationTabs(props: InstallationTabsProps) {
label: ( label: (
<FormattedMessage id="information" defaultMessage="Information" /> <FormattedMessage id="information" defaultMessage="Information" />
) )
},
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
} }
] ]
: [ : [
@ -259,15 +268,15 @@ function InstallationTabs(props: InstallationTabsProps) {
value: 'pvview', value: 'pvview',
label: <FormattedMessage id="pvview" defaultMessage="Pv View" /> label: <FormattedMessage id="pvview" defaultMessage="Pv View" />
}, },
{ // {
value: 'manage', // value: 'manage',
label: ( // label: (
<FormattedMessage // <FormattedMessage
id="manage" // id="manage"
defaultMessage="Access Management" // defaultMessage="Access Management"
/> // />
) // )
}, // },
{ {
value: 'log', value: 'log',
label: <FormattedMessage id="log" defaultMessage="Log" /> label: <FormattedMessage id="log" defaultMessage="Log" />
@ -303,6 +312,10 @@ function InstallationTabs(props: InstallationTabsProps) {
{ {
value: 'installationTickets', value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" /> label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
},
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
} }
] ]
: currentUser.userType == UserType.partner : currentUser.userType == UserType.partner
@ -348,6 +361,10 @@ function InstallationTabs(props: InstallationTabsProps) {
defaultMessage="Information" defaultMessage="Information"
/> />
) )
},
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
} }
] ]
: [ : [
@ -458,6 +475,10 @@ function InstallationTabs(props: InstallationTabsProps) {
<Navigate <Navigate
to={routes.sodistoregrid_installations + routes.list} to={routes.sodistoregrid_installations + routes.list}
/> />
) : props.product === 5 ? (
<Navigate
to={routes.sodistorepro_installations + routes.list}
/>
) : ( ) : (
<Navigate <Navigate
to={routes.sodistore_installations + routes.list} to={routes.sodistore_installations + routes.list}

View File

@ -248,9 +248,9 @@ function Log(props: LogProps) {
if (source === 'KnowledgeBase') if (source === 'KnowledgeBase')
return <Chip label="Knowledge Base" size="small" sx={{ bgcolor: '#1976d2', color: '#fff', fontWeight: 'bold' }} />; return <Chip label="Knowledge Base" size="small" sx={{ bgcolor: '#1976d2', color: '#fff', fontWeight: 'bold' }} />;
if (source === 'MistralAI') if (source === 'MistralAI')
return <Chip label="Mistral AI" size="small" sx={{ bgcolor: '#7b1fa2', color: '#fff', fontWeight: 'bold' }} />; return <Chip label="AI" size="small" sx={{ bgcolor: '#7b1fa2', color: '#fff', fontWeight: 'bold' }} />;
if (source === 'MistralFailed') if (source === 'MistralFailed')
return <Chip label="Mistral failed" size="small" color="error" />; return <Chip label="AI failed" size="small" color="error" />;
return <Chip label="Not available" size="small" color="default" />; return <Chip label="Not available" size="small" color="default" />;
}; };
@ -288,7 +288,7 @@ function Log(props: LogProps) {
onChange={e => { setDemoAlarm(e.target.value); setDemoResult(null); }} onChange={e => { setDemoAlarm(e.target.value); setDemoResult(null); }}
sx={{ minWidth: 260 }} sx={{ minWidth: 260 }}
> >
<ListSubheader>Sinexcel</ListSubheader> <ListSubheader>inesco 12K - WR Hybrid</ListSubheader>
{DEMO_ALARMS.sinexcel.map(a => ( {DEMO_ALARMS.sinexcel.map(a => (
<MenuItem key={a} value={a}>{a}</MenuItem> <MenuItem key={a} value={a}>{a}</MenuItem>
))} ))}

View File

@ -3,10 +3,12 @@ import React, {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useState useState
} from 'react'; } from 'react';
import { import {
Alert, Alert,
Autocomplete,
Box, Box,
Container, Container,
Divider, Divider,
@ -19,6 +21,7 @@ import {
MenuItem, MenuItem,
Modal, Modal,
Select, Select,
TextField,
Typography, Typography,
useTheme useTheme
} from '@mui/material'; } from '@mui/material';
@ -41,6 +44,16 @@ import {
} from '../../../interfaces/InstallationTypes'; } from '../../../interfaces/InstallationTypes';
import axiosConfig from '../../../Resources/axiosConfig'; import axiosConfig from '../../../Resources/axiosConfig';
const PRODUCT_GROUP_ORDER: number[] = [2, 5, 4, 3, 0, 1];
const PRODUCT_NAMES: Record<number, string> = {
0: 'Salimax',
1: 'Salidomo',
2: 'Sodistore Home',
3: 'Sodistore Max',
4: 'Sodistore Grid',
5: 'Sodistore Pro'
};
interface UserAccessProps { interface UserAccessProps {
current_user: InnovEnergyUser; current_user: InnovEnergyUser;
} }
@ -57,16 +70,25 @@ function UserAccess(props: UserAccessProps) {
const context = useContext(UserContext); const context = useContext(UserContext);
const { currentUser } = context; const { currentUser } = context;
const [openFolder, setOpenFolder] = useState(false); const [openFolder, setOpenFolder] = useState(false);
const [openInstallation, setOpenInstallation] = useState(false);
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
const [selectedFolderNames, setSelectedFolderNames] = useState<string[]>([]); const [selectedFolderNames, setSelectedFolderNames] = useState<string[]>([]);
const [selectedInstallationNames, setSelectedInstallationNames] = useState<string[]>([]); const [selectedInstallations, setSelectedInstallations] = useState<I_Installation[]>([]);
// Available choices for grant modal // Available choices for grant modal
const [availableFolders, setAvailableFolders] = useState<I_Folder[]>([]); const [availableFolders, setAvailableFolders] = useState<I_Folder[]>([]);
const [availableInstallations, setAvailableInstallations] = useState<I_Installation[]>([]); const [availableInstallations, setAvailableInstallations] = useState<I_Installation[]>([]);
const sortedInstallations = useMemo(() => {
const orderMap = new Map(PRODUCT_GROUP_ORDER.map((p, i) => [p, i]));
const sorted = [...availableInstallations].sort((a, b) => {
const oa = orderMap.get(a.product) ?? 99;
const ob = orderMap.get(b.product) ?? 99;
return oa !== ob ? oa - ob : a.name.localeCompare(b.name);
});
return sorted;
}, [availableInstallations]);
// Direct grants for this user // Direct grants for this user
const [directFolders, setDirectFolders] = useState<{ id: number; name: string }[]>([]); const [directFolders, setDirectFolders] = useState<{ id: number; name: string }[]>([]);
const [directInstallations, setDirectInstallations] = useState<{ id: number; name: string }[]>([]); const [directInstallations, setDirectInstallations] = useState<{ id: number; name: string }[]>([]);
@ -107,13 +129,14 @@ function UserAccess(props: UserAccessProps) {
const fetchAvailableInstallations = useCallback(async () => { const fetchAvailableInstallations = useCallback(async () => {
try { try {
const [res0, res1, res2, res3] = await Promise.all([ const products = [0, 1, 2, 3, 4, 5];
axiosConfig.get(`/GetAllInstallationsFromProduct?product=0`), const responses = await Promise.all(
axiosConfig.get(`/GetAllInstallationsFromProduct?product=1`), products.map((p) => axiosConfig.get(`/GetAllInstallationsFromProduct?product=${p}`))
axiosConfig.get(`/GetAllInstallationsFromProduct?product=2`), );
axiosConfig.get(`/GetAllInstallationsFromProduct?product=3`) const all = responses.flatMap((res, idx) =>
]); res.data.map((inst: I_Installation) => ({ ...inst, product: products[idx] }))
setAvailableInstallations([...res0.data, ...res1.data, ...res2.data, ...res3.data]); );
setAvailableInstallations(all);
} catch (err) { } catch (err) {
if (err.response && err.response.status === 401) removeToken(); if (err.response && err.response.status === 401) removeToken();
} }
@ -128,7 +151,7 @@ function UserAccess(props: UserAccessProps) {
fetchAvailableInstallations(); fetchAvailableInstallations();
setOpenModal(true); setOpenModal(true);
setSelectedFolderNames([]); setSelectedFolderNames([]);
setSelectedInstallationNames([]); setSelectedInstallations([]);
}; };
const handleRevokeFolder = async (folderId: number, folderName: string) => { const handleRevokeFolder = async (folderId: number, folderName: string) => {
@ -176,8 +199,7 @@ function UserAccess(props: UserAccessProps) {
}); });
} }
for (const installationName of selectedInstallationNames) { for (const installation of selectedInstallations) {
const installation = availableInstallations.find((i) => i.name === installationName);
await axiosConfig await axiosConfig
.post(`/GrantUserAccessToInstallation?UserId=${props.current_user.id}&InstallationId=${installation.id}`) .post(`/GrantUserAccessToInstallation?UserId=${props.current_user.id}&InstallationId=${installation.id}`)
.then(() => { .then(() => {
@ -262,31 +284,43 @@ function UserAccess(props: UserAccessProps) {
</div> </div>
<div> <div>
<FormControl fullWidth sx={{ marginTop: 2, width: 390 }}> <FormControl fullWidth sx={{ marginTop: 1, width: 390 }}>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}> <Autocomplete<I_Installation, true, false, false>
<FormattedMessage id="grantAccessToInstallations" defaultMessage="Grant access to installations" />
</InputLabel>
<Select
multiple multiple
value={selectedInstallationNames} options={sortedInstallations}
onChange={(e) => setSelectedInstallationNames(e.target.value as string[])} groupBy={(option) => PRODUCT_NAMES[option.product] || 'Unknown'}
open={openInstallation} getOptionLabel={(option) => option.name}
onClose={() => setOpenInstallation(false)} value={selectedInstallations}
onOpen={() => setOpenInstallation(true)} onChange={(_event, newValue) => setSelectedInstallations(newValue)}
renderValue={(selected) => ( isOptionEqualToValue={(option, value) => option.id === value.id}
<div>{selected.map((i) => <span key={i}>{i}, </span>)}</div> renderGroup={(params) => (
<li key={params.key}>
<Typography
sx={{
fontWeight: 'bold',
fontSize: 13,
padding: '4px 16px',
backgroundColor: theme.colors.alpha.black[5],
color: theme.colors.alpha.black[70]
}}
>
{params.group}
</Typography>
<ul style={{ padding: 0 }}>{params.children}</ul>
</li>
)} )}
> renderInput={(params) => (
{availableInstallations.map((installation) => ( <TextField
<MenuItem key={installation.id} value={installation.name}>{installation.name}</MenuItem> {...params}
))} label={intl.formatMessage({ id: 'grantAccessToInstallations' })}
<Button placeholder={intl.formatMessage({ id: 'searchInstallations' })}
sx={{ marginLeft: '150px', marginTop: '10px', backgroundColor: theme.colors.primary.main, color: 'white', '&:hover': { backgroundColor: theme.colors.primary.dark }, padding: '6px 8px' }} InputLabelProps={{
onClick={() => setOpenInstallation(false)} ...params.InputLabelProps,
> sx: { fontSize: 14, backgroundColor: 'white' }
<FormattedMessage id="submit" defaultMessage="Submit" /> }}
</Button> />
</Select> )}
/>
</FormControl> </FormControl>
</div> </div>

View File

@ -6,7 +6,8 @@ export const getChartOptions = (
chartInfo: chartInfoInterface, chartInfo: chartInfoInterface,
type: string, type: string,
dateList: string[], dateList: string[],
stacked: Boolean stacked: Boolean,
voltageInfo?: chartInfoInterface
): ApexOptions => { ): ApexOptions => {
return type.includes('daily') return type.includes('daily')
? { ? {
@ -32,6 +33,7 @@ export const getChartOptions = (
colors: ['#3498db', '#2ecc71', '#282828'], colors: ['#3498db', '#2ecc71', '#282828'],
xaxis: { xaxis: {
type: 'datetime', type: 'datetime',
tickAmount: 8,
labels: { labels: {
datetimeFormatter: { datetimeFormatter: {
year: 'yyyy', year: 'yyyy',
@ -51,6 +53,7 @@ export const getChartOptions = (
? [ ? [
{ {
seriesName: 'Grid Power', seriesName: 'Grid Power',
tickAmount: 6,
min: min:
chartInfo.min >= 0 chartInfo.min >= 0
? 0 ? 0
@ -88,6 +91,7 @@ export const getChartOptions = (
{ {
seriesName: 'Grid Power', seriesName: 'Grid Power',
show: false, show: false,
tickAmount: 6,
min: min:
chartInfo.min >= 0 chartInfo.min >= 0
? 0 ? 0
@ -104,15 +108,6 @@ export const getChartOptions = (
: chartInfo.max <= 0 : chartInfo.max <= 0
? 0 ? 0
: undefined, : undefined,
title: {
text: chartInfo.unit,
style: {
fontSize: '12px'
},
offsetY: -190,
offsetX: 25,
rotate: 0
},
labels: { labels: {
formatter: function (value: number) { formatter: function (value: number) {
return formatPowerForGraph( return formatPowerForGraph(
@ -122,11 +117,39 @@ export const getChartOptions = (
} }
} }
}, },
{ {
seriesName: 'State Of Charge', seriesName: 'Grid Power',
show: false,
tickAmount: 6,
min:
chartInfo.min >= 0
? 0
: chartInfo.max <= 0
? Math.ceil(
chartInfo.min / findPower(chartInfo.min).value
) * findPower(chartInfo.min).value
: undefined,
max:
chartInfo.min >= 0
? Math.ceil(
chartInfo.max / findPower(chartInfo.max).value
) * findPower(chartInfo.max).value
: chartInfo.max <= 0
? 0
: undefined,
labels: {
formatter: function (value: number) {
return formatPowerForGraph(
value,
chartInfo.magnitude
).value.toString();
}
}
},
{
seriesName: 'Battery SOC',
opposite: true, opposite: true,
tickAmount: 5,
min: 0, min: 0,
max: 100, max: 100,
title: { title: {
@ -140,12 +163,34 @@ export const getChartOptions = (
}, },
labels: { labels: {
formatter: function (value: number) { formatter: function (value: number) {
return formatPowerForGraph(value, 0).value.toString(); return Math.round(value).toString();
} }
} }
},
...(voltageInfo ? [{
seriesName: 'Battery Voltage',
opposite: true,
tickAmount: 5,
min: voltageInfo.min > 0 ? Math.floor(voltageInfo.min / 5) * 5 : 0,
max: Math.ceil(voltageInfo.max / 5) * 5,
title: {
text: '(V)',
style: {
fontSize: '12px'
},
offsetY: -190,
offsetX: -45,
rotate: 0
},
labels: {
formatter: function (value: number) {
return Math.round(value).toString();
} }
}
}] : [])
] ]
: { : {
tickAmount: chartInfo.unit === '(%)' ? 5 : 6,
min: min:
chartInfo.min >= 0 chartInfo.min >= 0
? 0 ? 0
@ -173,6 +218,9 @@ export const getChartOptions = (
}, },
labels: { labels: {
formatter: function (value: number) { formatter: function (value: number) {
if (chartInfo.unit === '(%)') {
return Math.round(value).toString();
}
return formatPowerForGraph( return formatPowerForGraph(
value, value,
chartInfo.magnitude chartInfo.magnitude
@ -189,7 +237,7 @@ export const getChartOptions = (
y: { y: {
formatter: function (val, { seriesIndex, w }) { formatter: function (val, { seriesIndex, w }) {
const seriesName = w.config.series[seriesIndex].name; const seriesName = w.config.series[seriesIndex].name;
if (seriesName === 'State Of Charge') { if (seriesName === 'Battery SOC') {
return val.toFixed(2) + ' %'; return val.toFixed(2) + ' %';
} else { } else {
return ( return (
@ -255,6 +303,7 @@ export const getChartOptions = (
} }
}, },
yaxis: { yaxis: {
tickAmount: 6,
min: min:
chartInfo.min >= 0 chartInfo.min >= 0
? 0 ? 0

View File

@ -34,6 +34,7 @@ interface OverviewProps {
s3Credentials: I_S3Credentials; s3Credentials: I_S3Credentials;
id: number; id: number;
device?: number; device?: number;
product?: number;
connected?: boolean; connected?: boolean;
loading?: boolean; loading?: boolean;
} }
@ -565,7 +566,7 @@ function Overview(props: OverviewProps) {
> >
<FormattedMessage id="24_hours" defaultMessage="24-hours" /> <FormattedMessage id="24_hours" defaultMessage="24-hours" />
</Button> </Button>
{props.device !== 3 && ( {props.device !== 3 && props.product !== 2 && (
<Button <Button
variant="contained" variant="contained"
onClick={handleWeekData} onClick={handleWeekData}
@ -711,7 +712,8 @@ function Overview(props: OverviewProps) {
dailyDataArray[chartState].chartOverview.overview, dailyDataArray[chartState].chartOverview.overview,
'dailyoverview', 'dailyoverview',
[], [],
true true,
(product === 2 || product === 5) ? dailyDataArray[chartState].chartOverview.batteryVoltage : undefined
), ),
chart: { chart: {
events: { events: {
@ -734,11 +736,21 @@ function Overview(props: OverviewProps) {
type: 'line', type: 'line',
color: '#ff9900' color: '#ff9900'
}, },
{
...dailyDataArray[chartState].chartData.ACLoad,
type: 'line',
color: '#2ecc71'
},
{ {
...dailyDataArray[chartState].chartData.soc, ...dailyDataArray[chartState].chartData.soc,
type: 'line', type: 'line',
color: '#008FFB' color: '#008FFB'
} },
...((product === 2 || product === 5) ? [{
...dailyDataArray[chartState].chartData.batteryVoltage,
type: 'line' as const,
color: '#9b59b6'
}] : [])
]} ]}
height={420} height={420}
/> />
@ -817,7 +829,7 @@ function Overview(props: OverviewProps) {
type: 'bar', type: 'bar',
color: '#ff9900' color: '#ff9900'
}, },
...(product !== 2 ? [{ ...((product !== 2 && product !== 5) ? [{
name: 'Net Energy', name: 'Net Energy',
color: '#e65100', color: '#e65100',
type: 'line', type: 'line',
@ -840,7 +852,7 @@ function Overview(props: OverviewProps) {
alignItems="stretch" alignItems="stretch"
spacing={3} spacing={3}
> >
{!(aggregatedData && product === 2) && ( {!(aggregatedData && (product === 2 || product === 5)) && (
<Grid item md={6} xs={12}> <Grid item md={6} xs={12}>
<Card <Card
sx={{ sx={{
@ -933,7 +945,7 @@ function Overview(props: OverviewProps) {
</Card> </Card>
</Grid> </Grid>
)} )}
<Grid item md={(aggregatedData && product === 2) ? 12 : 6} xs={12}> <Grid item md={(aggregatedData && (product === 2 || product === 5)) ? 12 : 6} xs={12}>
<Card <Card
sx={{ sx={{
overflow: 'visible', overflow: 'visible',
@ -1001,14 +1013,14 @@ function Overview(props: OverviewProps) {
<ReactApexChart <ReactApexChart
options={{ options={{
...getChartOptions( ...getChartOptions(
product === 2 (product === 2 || product === 5)
? aggregatedDataArray[aggregatedChartState] ? aggregatedDataArray[aggregatedChartState]
.chartOverview.dcPowerWithoutHeating .chartOverview.dcPowerWithoutHeating
: aggregatedDataArray[aggregatedChartState] : aggregatedDataArray[aggregatedChartState]
.chartOverview.dcPower, .chartOverview.dcPower,
'weekly', 'weekly',
aggregatedDataArray[aggregatedChartState].datelist, aggregatedDataArray[aggregatedChartState].datelist,
product === 2 (product === 2 || product === 5)
) )
}} }}
series={[ series={[
@ -1017,7 +1029,7 @@ function Overview(props: OverviewProps) {
.chartData.dcChargingPower, .chartData.dcChargingPower,
color: '#008FFB' color: '#008FFB'
}, },
...(product !== 2 ? [{ ...((product !== 2 && product !== 5) ? [{
...aggregatedDataArray[aggregatedChartState] ...aggregatedDataArray[aggregatedChartState]
.chartData.heatingPower, .chartData.heatingPower,
color: '#ff9900' color: '#ff9900'
@ -1073,7 +1085,7 @@ function Overview(props: OverviewProps) {
alignItems="stretch" alignItems="stretch"
spacing={3} spacing={3}
> >
{product !== 2 && ( {(product !== 2 && product !== 5) && (
<Grid item md={6} xs={12}> <Grid item md={6} xs={12}>
<Card <Card
sx={{ sx={{
@ -1136,7 +1148,7 @@ function Overview(props: OverviewProps) {
</Card> </Card>
</Grid> </Grid>
)} )}
{product !== 2 && ( {(product !== 2 && product !== 5) && (
<Grid item md={6} xs={12}> <Grid item md={6} xs={12}>
<Card <Card
sx={{ sx={{
@ -1392,7 +1404,7 @@ function Overview(props: OverviewProps) {
</Grid> </Grid>
</Grid> </Grid>
{aggregatedData && product === 2 && ( {aggregatedData && (product === 2 || product === 5) && (
<Grid <Grid
container container
direction="row" direction="row"
@ -1457,7 +1469,7 @@ function Overview(props: OverviewProps) {
alignItems="stretch" alignItems="stretch"
spacing={3} spacing={3}
> >
<Grid item md={product === 2 ? 12 : 6} xs={12}> <Grid item md={(product === 2 || product === 5) ? 12 : 6} xs={12}>
<Card <Card
sx={{ sx={{
overflow: 'visible', overflow: 'visible',
@ -1518,7 +1530,7 @@ function Overview(props: OverviewProps) {
/> />
</Card> </Card>
</Grid> </Grid>
{product !== 2 && ( {(product !== 2 && product !== 5) && (
<Grid item md={6} xs={12}> <Grid item md={6} xs={12}>
<Card <Card
sx={{ sx={{

View File

@ -24,6 +24,7 @@ import { UserType } from '../../../interfaces/UserTypes';
import HistoryOfActions from '../History/History'; import HistoryOfActions from '../History/History';
import BuildIcon from '@mui/icons-material/Build'; import BuildIcon from '@mui/icons-material/Build';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
import AccessContextProvider from '../../../contexts/AccessContextProvider'; import AccessContextProvider from '../../../contexts/AccessContextProvider';
import Access from '../ManageAccess/Access'; import Access from '../ManageAccess/Access';
@ -105,7 +106,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
setLoading(false); setLoading(false);
console.log('NUMBER OF FILES=' + Object.keys(res).length); console.log('NUMBER OF FILES=' + Object.keys(res).length);
console.log('res=', res); console.log('res= [S3 credentials hidden]');
while (continueFetching.current) { while (continueFetching.current) {
for (const timestamp of Object.keys(res)) { for (const timestamp of Object.keys(res)) {
@ -114,7 +115,8 @@ function SalidomoInstallation(props: singleInstallationProps) {
return false; return false;
} }
console.log(`Timestamp: ${timestamp}`); console.log(`Timestamp: ${timestamp}`);
console.log('object is', res); const { Config: { S3: { Key, Secret, ...s3Rest } = {} as any, ...configRest } = {} as any, ...dataRest } = res[timestamp] || {};
console.log('object is', { ...dataRest, Config: { ...configRest, S3: { ...s3Rest, Key: '***', Secret: '***' } } });
// Set values asynchronously with delay // Set values asynchronously with delay
setValues(res[timestamp]); setValues(res[timestamp]);
@ -323,7 +325,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
</div> </div>
{loading && {loading &&
currentTab != 'information' && currentTab != 'information' &&
currentTab != 'manage' && // currentTab != 'manage' &&
currentTab != 'history' && currentTab != 'history' &&
currentTab != 'log' && currentTab != 'log' &&
currentTab != 'installationTickets' && ( currentTab != 'installationTickets' && (
@ -416,7 +418,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
/> />
)} )}
{currentUser.userType == UserType.admin && ( {/* {currentUser.userType == UserType.admin && (
<Route <Route
path={routes.manage} path={routes.manage}
element={ element={
@ -428,7 +430,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
</AccessContextProvider> </AccessContextProvider>
} }
/> />
)} )} */}
{currentUser.userType == UserType.admin && ( {currentUser.userType == UserType.admin && (
<Route <Route
@ -441,6 +443,17 @@ function SalidomoInstallation(props: singleInstallationProps) {
/> />
)} )}
{(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && (
<Route
path={routes.documents}
element={
<DocumentsTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route <Route
path={'*'} path={'*'}
element={<Navigate to={routes.batteryview}></Navigate>} element={<Navigate to={routes.batteryview}></Navigate>}

View File

@ -25,11 +25,12 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
const tabList = [ const tabList = [
'batteryview', 'batteryview',
'information', 'information',
'manage', // 'manage',
'overview', 'overview',
'log', 'log',
'history', 'history',
'installationTickets' 'installationTickets',
'documents'
]; ];
const [currentTab, setCurrentTab] = useState<string>(undefined); const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -113,15 +114,15 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
label: <FormattedMessage id="log" defaultMessage="Log" /> label: <FormattedMessage id="log" defaultMessage="Log" />
}, },
{ // {
value: 'manage', // value: 'manage',
label: ( // label: (
<FormattedMessage // <FormattedMessage
id="manage" // id="manage"
defaultMessage="Access Management" // defaultMessage="Access Management"
/> // />
) // )
}, // },
{ {
value: 'information', value: 'information',
@ -141,6 +142,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
{ {
value: 'installationTickets', value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" /> label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
},
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
} }
] ]
: [ : [
@ -198,15 +203,15 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
label: <FormattedMessage id="log" defaultMessage="Log" /> label: <FormattedMessage id="log" defaultMessage="Log" />
}, },
{ // {
value: 'manage', // value: 'manage',
label: ( // label: (
<FormattedMessage // <FormattedMessage
id="manage" // id="manage"
defaultMessage="Access Management" // defaultMessage="Access Management"
/> // />
) // )
}, // },
{ {
value: 'information', value: 'information',
@ -226,6 +231,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
{ {
value: 'installationTickets', value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" /> label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
},
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
} }
] ]
: currentTab != 'list' && : currentTab != 'list' &&

View File

@ -56,19 +56,6 @@ interface HourlyEnergyRecord {
// ── Date Helpers ───────────────────────────────────────────── // ── Date Helpers ─────────────────────────────────────────────
/**
* Returns the Monday of the current week.
*/
function getCurrentMonday(): Date {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dow = today.getDay(); // 0=Sun
const offset = dow === 0 ? 6 : dow - 1; // Mon=0 offset
const monday = new Date(today);
monday.setDate(today.getDate() - offset);
return monday;
}
function formatDateISO(d: Date): string { function formatDateISO(d: Date): string {
const y = d.getFullYear(); const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0'); const m = String(d.getMonth() + 1).padStart(2, '0');
@ -77,19 +64,20 @@ function formatDateISO(d: Date): string {
} }
/** /**
* Returns current week Monyesterday. Today excluded because * Returns the last 7 days ending yesterday.
* S3 aggregated file is not available until end of day. * Today is excluded because S3 aggregated file is not available until ~01:00 UTC the next day.
*/ */
function getCurrentWeekDays(currentMonday: Date): Date[] { function getLast7Days(): Date[] {
const yesterday = new Date(); const yesterday = new Date();
yesterday.setHours(0, 0, 0, 0); yesterday.setHours(0, 0, 0, 0);
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
const days: Date[] = []; const days: Date[] = [];
for (let i = 6; i >= 0; i--) {
for (let d = new Date(currentMonday); d <= yesterday; d.setDate(d.getDate() + 1)) { const d = new Date(yesterday);
days.push(new Date(d)); d.setDate(yesterday.getDate() - i);
days.push(d);
} }
return days; return days;
} }
@ -105,7 +93,6 @@ export default function DailySection({
onPeriodChange?: (date: string) => void; onPeriodChange?: (date: string) => void;
}) { }) {
const intl = useIntl(); const intl = useIntl();
const currentMonday = useMemo(() => getCurrentMonday(), []);
const yesterday = useMemo(() => { const yesterday = useMemo(() => {
const d = new Date(); const d = new Date();
d.setHours(0, 0, 0, 0); d.setHours(0, 0, 0, 0);
@ -125,11 +112,8 @@ export default function DailySection({
const [loadingWeek, setLoadingWeek] = useState(false); const [loadingWeek, setLoadingWeek] = useState(false);
const [noData, setNoData] = useState(false); const [noData, setNoData] = useState(false);
// Current week Mon→yesterday only // Rolling 7-day window ending yesterday
const weekDays = useMemo( const weekDays = useMemo(() => getLast7Days(), []);
() => getCurrentWeekDays(currentMonday),
[currentMonday]
);
// Fetch data for current week days // Fetch data for current week days
useEffect(() => { useEffect(() => {
@ -193,7 +177,7 @@ export default function DailySection({
return ( return (
<> <>
{/* Day Strip — current week Mon→yesterday */} {/* Day Strip — last 7 days ending yesterday */}
<DayStrip <DayStrip
weekDays={weekDays} weekDays={weekDays}
selectedDate={selectedDate} selectedDate={selectedDate}
@ -344,7 +328,7 @@ function DayStrip({
<Typography variant="caption" sx={{ color: '#888' }}> <Typography variant="caption" sx={{ color: '#888' }}>
<FormattedMessage <FormattedMessage
id="currentWeekHint" id="currentWeekHint"
defaultMessage="Current week (Monyesterday)" defaultMessage="Last 7 days"
/> />
</Typography> </Typography>
</Box> </Box>
@ -353,9 +337,10 @@ function DayStrip({
// ── IntradayChart ──────────────────────────────────────────── // ── IntradayChart ────────────────────────────────────────────
const HOUR_LABELS = Array.from({ length: 24 }, (_, i) => const HOUR_LABELS = [
`${String(i).padStart(2, '0')}:00` ...Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`),
); '24:00'
];
function IntradayChart({ function IntradayChart({
hourlyData, hourlyData,
@ -387,13 +372,14 @@ function IntradayChart({
const hourMap = new Map(hourlyData.map((h) => [h.hour, h])); const hourMap = new Map(hourlyData.map((h) => [h.hour, h]));
const pvData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.pvKwh ?? null); const getHour = (i: number) => hourMap.get(i === 24 ? 23 : i);
const loadData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.loadKwh ?? null); const pvData = HOUR_LABELS.map((_, i) => getHour(i)?.pvKwh ?? null);
const loadData = HOUR_LABELS.map((_, i) => getHour(i)?.loadKwh ?? null);
const batteryData = HOUR_LABELS.map((_, i) => { const batteryData = HOUR_LABELS.map((_, i) => {
const h = hourMap.get(i); const h = getHour(i);
return h ? h.batteryDischargedKwh - h.batteryChargedKwh : null; return h ? h.batteryDischargedKwh - h.batteryChargedKwh : null;
}); });
const socData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.battSoC ?? null); const socData = HOUR_LABELS.map((_, i) => getHour(i)?.battSoC ?? null);
const chartData = { const chartData = {
labels: HOUR_LABELS, labels: HOUR_LABELS,

View File

@ -19,15 +19,18 @@ import { useLocation, useNavigate } from 'react-router-dom';
import routes from '../../../Resources/routes.json'; import routes from '../../../Resources/routes.json';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import BuildIcon from '@mui/icons-material/Build'; import BuildIcon from '@mui/icons-material/Build';
import { getDeviceTypeName } from '../Information/installationSetupUtils';
interface FlatInstallationViewProps { interface FlatInstallationViewProps {
installations: I_Installation[]; installations: I_Installation[];
product?: number;
} }
const FlatInstallationView = (props: FlatInstallationViewProps) => { const FlatInstallationView = (props: FlatInstallationViewProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1); const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
const currentLocation = useLocation(); const currentLocation = useLocation();
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
// //
const sortedInstallations = [...props.installations].sort((a, b) => { const sortedInstallations = [...props.installations].sort((a, b) => {
// Compare the status field of each installation and sort them based on the status. // Compare the status field of each installation and sort them based on the status.
@ -50,7 +53,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
setSelectedInstallation(-1); setSelectedInstallation(-1);
navigate( navigate(
routes.sodiohome_installations + baseRoute +
routes.list + routes.list +
routes.installation + routes.installation +
`${installationID}` + `${installationID}` +
@ -81,9 +84,9 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
sx={{ sx={{
display: display:
currentLocation.pathname === currentLocation.pathname ===
routes.sodiohome_installations + 'list' || baseRoute + 'list' ||
currentLocation.pathname === currentLocation.pathname ===
routes.sodiohome_installations + routes.list baseRoute + routes.list
? 'block' ? 'block'
: 'none' : 'none'
}} }}
@ -96,14 +99,14 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<TableCell> <TableCell>
<FormattedMessage id="name" defaultMessage="Name" /> <FormattedMessage id="name" defaultMessage="Name" />
</TableCell> </TableCell>
<TableCell>
<FormattedMessage id="location" defaultMessage="Location" />
</TableCell>
<TableCell> <TableCell>
<FormattedMessage id="installationSN" defaultMessage="Installation SN" /> <FormattedMessage id="installationSN" defaultMessage="Installation SN" />
</TableCell> </TableCell>
<TableCell> <TableCell>
<FormattedMessage id="region" defaultMessage="Region" /> <FormattedMessage id="DeviceType" defaultMessage="Device Type" />
</TableCell>
<TableCell>
<FormattedMessage id="canton" defaultMessage="Canton" />
</TableCell> </TableCell>
<TableCell> <TableCell>
<FormattedMessage id="country" defaultMessage="Country" /> <FormattedMessage id="country" defaultMessage="Country" />
@ -146,19 +149,6 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
</Typography> </Typography>
</TableCell> </TableCell>
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.location}
</Typography>
</TableCell>
<TableCell> <TableCell>
<Typography <Typography
variant="body2" variant="body2"
@ -181,7 +171,20 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
noWrap noWrap
sx={{ marginTop: '10px', fontSize: 'small' }} sx={{ marginTop: '10px', fontSize: 'small' }}
> >
{installation.region} {getDeviceTypeName(installation.device)}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.canton || ''}
</Typography> </Typography>
</TableCell> </TableCell>

View File

@ -29,6 +29,7 @@ import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
import Overview from '../Overview/overview'; import Overview from '../Overview/overview';
import WeeklyReport from './WeeklyReport'; import WeeklyReport from './WeeklyReport';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
interface singleInstallationProps { interface singleInstallationProps {
current_installation?: I_Installation; current_installation?: I_Installation;
@ -50,7 +51,9 @@ function SodioHomeInstallation(props: singleInstallationProps) {
const s3Bucket = const s3Bucket =
props.current_installation.s3BucketId.toString() + props.current_installation.s3BucketId.toString() +
'-' + '-' +
'e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa'; (props.current_installation.product === 5
? '325c9373-9025-4a8d-bf5a-f9eedf1f155c'
: 'e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa');
const context = useContext(UserContext); const context = useContext(UserContext);
const { currentUser } = context; const { currentUser } = context;
@ -181,7 +184,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
return false; return false;
} }
console.log(`Timestamp: ${timestamp}`); console.log(`Timestamp: ${timestamp}`);
console.log(res[timestamp]); const { Config: { S3: { Key, Secret, ...s3Rest } = {} as any, ...configRest } = {} as any, ...dataRest } = res[timestamp] || {};
console.log({ ...dataRest, Config: { ...configRest, S3: { ...s3Rest, Key: '***', Secret: '***' } } });
setValues(res[timestamp]); setValues(res[timestamp]);
await timeout(2000); await timeout(2000);
@ -471,7 +475,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
</div> </div>
{loading && {loading &&
currentTab != 'information' && currentTab != 'information' &&
currentTab != 'manage' && // currentTab != 'manage' &&
currentTab != 'history' && currentTab != 'history' &&
currentTab != 'log' && currentTab != 'log' &&
currentTab != 'report' && currentTab != 'report' &&
@ -582,7 +586,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
/> />
)} )}
{currentUser.userType == UserType.admin && ( {/* {currentUser.userType == UserType.admin && (
<Route <Route
path={routes.manage} path={routes.manage}
element={ element={
@ -594,7 +598,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
</AccessContextProvider> </AccessContextProvider>
} }
/> />
)} )} */}
<Route <Route
path={routes.overview} path={routes.overview}
@ -603,6 +607,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
s3Credentials={s3Credentials} s3Credentials={s3Credentials}
id={props.current_installation.id} id={props.current_installation.id}
device={props.current_installation.device} device={props.current_installation.device}
product={props.current_installation.product}
connected={connected} connected={connected}
loading={loading} loading={loading}
/> />
@ -615,6 +620,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
element={ element={
<WeeklyReport <WeeklyReport
installationId={props.current_installation.id} installationId={props.current_installation.id}
installationName={props.current_installation.name}
installationEmail={props.current_installation.email}
/> />
} }
/> />
@ -631,6 +638,17 @@ function SodioHomeInstallation(props: singleInstallationProps) {
/> />
)} )}
{(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && (
<Route
path={routes.documents}
element={
<DocumentsTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route <Route
path={'*'} path={'*'}
element={<Navigate to={routes.live}></Navigate>} element={<Navigate to={routes.live}></Navigate>}

View File

@ -10,12 +10,14 @@ import SodioHomeInstallation from './Installation';
interface installationSearchProps { interface installationSearchProps {
installations: I_Installation[]; installations: I_Installation[];
product?: number;
} }
function InstallationSearch(props: installationSearchProps) { function InstallationSearch(props: installationSearchProps) {
const intl = useIntl(); const intl = useIntl();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const currentLocation = useLocation(); const currentLocation = useLocation();
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
// const [filteredData, setFilteredData] = useState(props.installations); // const [filteredData, setFilteredData] = useState(props.installations);
const indexedData = useMemo(() => { const indexedData = useMemo(() => {
@ -46,9 +48,9 @@ function InstallationSearch(props: installationSearchProps) {
sx={{ sx={{
display: display:
currentLocation.pathname === currentLocation.pathname ===
routes.sodiohome_installations + 'list' || baseRoute + 'list' ||
currentLocation.pathname === currentLocation.pathname ===
routes.sodiohome_installations + routes.list baseRoute + routes.list
? 'block' ? 'block'
: 'none' : 'none'
}} }}
@ -79,7 +81,7 @@ function InstallationSearch(props: installationSearchProps) {
</Grid> </Grid>
</Grid> </Grid>
<FlatInstallationView installations={filteredData} /> <FlatInstallationView installations={filteredData} product={props.product} />
<Routes> <Routes>
{filteredData.map((installation) => { {filteredData.map((installation) => {
return ( return (

View File

@ -17,48 +17,57 @@ import { Close as CloseIcon } from '@mui/icons-material';
import { I_Installation } from 'src/interfaces/InstallationTypes'; import { I_Installation } from 'src/interfaces/InstallationTypes';
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider'; import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { INSTALLATION_PRESETS } from '../Information/installationSetupUtils'; import { getPresetsForDevice, SODIOHOME_DEVICE_TYPES } from '../Information/installationSetupUtils';
interface SodistorehomeInstallationFormPros { interface SodistorehomeInstallationFormPros {
cancel: () => void; cancel: () => void;
submit: () => void; submit: () => void;
parentid: number; parentid: number;
product?: number;
} }
function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros) { function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros) {
const theme = useTheme(); const theme = useTheme();
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const isSodistorePro = props.product === 5;
const [formValues, setFormValues] = useState<Partial<I_Installation>>({ const [formValues, setFormValues] = useState<Partial<I_Installation>>({
name: '', name: '',
region: '',
location: '',
country: '',
vpnIp: '', vpnIp: '',
installationModel: '', installationModel: '',
externalEms: 'No', externalEms: 'No',
...(isSodistorePro ? { device: 4 } : {}),
}); });
const requiredFields = ['name', 'location', 'country', 'vpnIp', 'installationModel']; const [inverterCount, setInverterCount] = useState('');
const requiredFields = ['name', 'vpnIp', ...(isSodistorePro ? [] : ['device', 'installationModel'])];
const DeviceTypes = [ const DeviceTypes = isSodistorePro
{ id: 3, name: 'Growatt' }, ? [{ id: 4, name: 'inesco 12K - WR Hybrid' }]
{ id: 4, name: 'Sinexcel' } : SODIOHOME_DEVICE_TYPES;
];
const installationContext = useContext(InstallationsContext); const installationContext = useContext(InstallationsContext);
const { createInstallation, loading, setLoading, error, setError } = const { createInstallation, loading, setLoading, error, setError } =
installationContext; installationContext;
const handleChange = (e) => { const handleChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
const updated = { ...formValues, [name]: value };
setFormValues({ // Reset preset when device type changes if current preset is invalid
...formValues, if (name === 'device' && !isSodistorePro) {
[name]: value const newDevicePresets = getPresetsForDevice(Number(value));
}); if (formValues.installationModel && !newDevicePresets[formValues.installationModel]) {
updated.installationModel = '';
}
}
setFormValues(updated);
}; };
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
setLoading(true); setLoading(true);
formValues.parentId = props.parentid; formValues.parentId = props.parentid;
formValues.product = 2; formValues.product = props.product ?? 2;
if (isSodistorePro) {
formValues.installationModel = inverterCount;
}
const responseData = await createInstallation(formValues); const responseData = await createInstallation(formValues);
props.submit(); props.submit();
}; };
@ -72,6 +81,9 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
return false; return false;
} }
} }
if (isSodistorePro && (!inverterCount || parseInt(inverterCount, 10) < 1)) {
return false;
}
return true; return true;
}; };
@ -127,42 +139,6 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
error={formValues.name === ''} error={formValues.name === ''}
/> />
</div> </div>
<div>
<TextField
label={<FormattedMessage id="region" defaultMessage="Region" />}
name="region"
value={formValues.region}
onChange={handleChange}
required
error={formValues.region === ''}
/>
</div>
<div>
<TextField
label={
<FormattedMessage id="location" defaultMessage="Location" />
}
name="location"
value={formValues.location}
onChange={handleChange}
required
error={formValues.location === ''}
/>
</div>
<div>
<TextField
label={
<FormattedMessage id="country" defaultMessage="Country" />
}
name="country"
value={formValues.country}
onChange={handleChange}
required
error={formValues.country === ''}
/>
</div>
<div> <div>
<TextField <TextField
label={<FormattedMessage id="VpnIp" defaultMessage="VpnIp" />} label={<FormattedMessage id="VpnIp" defaultMessage="VpnIp" />}
@ -174,10 +150,71 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
/> />
</div> </div>
{isSodistorePro ? (
<div>
<TextField
label={
<FormattedMessage
id="numberOfInverters"
defaultMessage="Number of Inverters"
/>
}
name="inverterCount"
type="text"
value={inverterCount}
onChange={(e) => {
const val = e.target.value;
if (val === '' || (/^\d+$/.test(val) && parseInt(val, 10) <= 20)) {
setInverterCount(val);
}
}}
required
error={!inverterCount || parseInt(inverterCount, 10) < 1}
/>
</div>
) : (
<>
{/* Device type must be selected before model — it determines available presets */}
<div> <div>
<FormControl <FormControl
fullWidth fullWidth
required required
sx={{
marginTop: 1,
marginBottom: 1,
width: 390
}}
>
<InputLabel
sx={{
fontSize: 14,
backgroundColor: 'white'
}}
>
<FormattedMessage
id="DeviceType"
defaultMessage="Device Type"
/>
</InputLabel>
<Select
name="device"
value={formValues.device ?? ''}
onChange={handleChange}
>
{DeviceTypes.map((device) => (
<MenuItem key={device.id} value={device.id}>
{device.name}
</MenuItem>
))}
</Select>
</FormControl>
</div>
<div>
<FormControl
fullWidth
required
disabled={!formValues.device}
error={formValues.installationModel === ''} error={formValues.installationModel === ''}
sx={{ sx={{
marginTop: 1, marginTop: 1,
@ -201,7 +238,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
value={formValues.installationModel || ''} value={formValues.installationModel || ''}
onChange={handleChange} onChange={handleChange}
> >
{Object.keys(INSTALLATION_PRESETS).map((name) => ( {Object.keys(getPresetsForDevice(formValues.device as number)).map((name) => (
<MenuItem key={name} value={name}> <MenuItem key={name} value={name}>
{name} {name}
</MenuItem> </MenuItem>
@ -209,40 +246,8 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
</Select> </Select>
</FormControl> </FormControl>
</div> </div>
</>
<div> )}
<FormControl
fullWidth
sx={{
marginTop: 1,
marginBottom: 1,
width: 390
}}
>
<InputLabel
sx={{
fontSize: 14,
backgroundColor: 'white'
}}
>
<FormattedMessage
id="DeviceType"
defaultMessage="Device Type"
/>
</InputLabel>
<Select
name="device"
value={formValues.device}
onChange={handleChange}
>
{DeviceTypes.map((device) => (
<MenuItem key={device.id} value={device.id}>
{device.name}
</MenuItem>
))}
</Select>
</FormControl>
</div>
</Box> </Box>
<div <div

View File

@ -13,6 +13,8 @@ import TreeView from '../Tree/treeView';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider'; import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
import { UserType } from '../../../interfaces/UserTypes'; import { UserType } from '../../../interfaces/UserTypes';
import SodioHomeInstallation from './Installation'; import SodioHomeInstallation from './Installation';
import AcknowledgementDialog, { CURRENT_TERMS_VERSION } from '../../../components/AcknowledgementDialog';
import axiosConfig from '../../../Resources/axiosConfig';
interface SodioHomeInstallationTabsProps { interface SodioHomeInstallationTabsProps {
product: number; product: number;
@ -21,18 +23,37 @@ interface SodioHomeInstallationTabsProps {
function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
const location = useLocation(); const location = useLocation();
const context = useContext(UserContext); const context = useContext(UserContext);
const { currentUser } = context; const { currentUser, setUser } = context;
const showTermsDialog =
currentUser?.acknowledgedTermsVersion == null ||
currentUser.acknowledgedTermsVersion < CURRENT_TERMS_VERSION;
const handleAcknowledgeTerms = () => {
axiosConfig
.put('/AcknowledgeTerms', undefined, {
params: { version: CURRENT_TERMS_VERSION }
})
.then(() => {
const updatedUser = {
...currentUser,
acknowledgedTermsVersion: CURRENT_TERMS_VERSION
};
setUser(updatedUser);
});
};
const tabList = [ const tabList = [
'live', 'live',
'overview', 'overview',
'batteryview', 'batteryview',
'information', 'information',
'manage', // 'manage',
'log', 'log',
'history', 'history',
'configuration', 'configuration',
'report', 'report',
'installationTickets' 'installationTickets',
'documents'
]; ];
const [currentTab, setCurrentTab] = useState<string>(undefined); const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -40,12 +61,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
useState<boolean>(false); useState<boolean>(false);
const { const {
sodiohomeInstallations, sodiohomeInstallations,
sodistoreProInstallations,
fetchAllInstallations, fetchAllInstallations,
socket, socket,
openSocket, openSocket,
closeSocket closeSocket
} = useContext(InstallationsContext); } = useContext(InstallationsContext);
const { product, setProduct } = useContext(ProductIdContext); const { product, setProduct } = useContext(ProductIdContext);
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
const installations = props.product === 5 ? sodistoreProInstallations : sodiohomeInstallations;
useEffect(() => { useEffect(() => {
let path = location.pathname.split('/'); let path = location.pathname.split('/');
@ -119,15 +143,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
value: 'log', value: 'log',
label: <FormattedMessage id="log" defaultMessage="Log" /> label: <FormattedMessage id="log" defaultMessage="Log" />
}, },
{ // {
value: 'manage', // value: 'manage',
label: ( // label: (
<FormattedMessage // <FormattedMessage
id="manage" // id="manage"
defaultMessage="Access Management" // defaultMessage="Access Management"
/> // />
) // )
}, // },
{ {
value: 'information', value: 'information',
label: ( label: (
@ -164,6 +188,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
{ {
value: 'installationTickets', value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" /> label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
},
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
} }
] ]
: currentUser.userType == UserType.partner : currentUser.userType == UserType.partner
@ -203,6 +231,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
defaultMessage="Report" defaultMessage="Report"
/> />
) )
},
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
} }
] ]
: [ : [
@ -237,11 +269,11 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
!location.pathname.includes('folder'); !location.pathname.includes('folder');
// Determine if current installation is Growatt (device=3) to hide report tab // Determine if current installation is Growatt (device=3) to hide report tab
const currentInstallation = sodiohomeInstallations.find((i) => const currentInstallation = installations.find((i) =>
location.pathname.includes(`/${i.id}/`) location.pathname.includes(`/${i.id}/`)
); );
const isGrowatt = currentInstallation?.device === 3 const isGrowatt = currentInstallation?.device === 3
|| (sodiohomeInstallations.length === 1 && sodiohomeInstallations[0].device === 3); || (installations.length === 1 && installations[0].device === 3);
const tabs = inInstallationView && currentUser.userType == UserType.admin const tabs = inInstallationView && currentUser.userType == UserType.admin
? [ ? [
@ -274,15 +306,15 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
value: 'log', value: 'log',
label: <FormattedMessage id="log" defaultMessage="Log" /> label: <FormattedMessage id="log" defaultMessage="Log" />
}, },
{ // {
value: 'manage', // value: 'manage',
label: ( // label: (
<FormattedMessage // <FormattedMessage
id="manage" // id="manage"
defaultMessage="Access Management" // defaultMessage="Access Management"
/> // />
) // )
}, // },
{ {
value: 'information', value: 'information',
label: ( label: (
@ -319,6 +351,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
{ {
value: 'installationTickets', value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" /> label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
},
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
} }
] ]
: inInstallationView && currentUser.userType == UserType.partner : inInstallationView && currentUser.userType == UserType.partner
@ -366,6 +402,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
defaultMessage="Report" defaultMessage="Report"
/> />
) )
},
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
} }
] ]
: inInstallationView && currentUser.userType == UserType.client : inInstallationView && currentUser.userType == UserType.client
@ -413,8 +453,12 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
} }
]; ];
return sodiohomeInstallations.length > 1 ? ( return installations.length > 1 ? (
<> <>
<AcknowledgementDialog
open={showTermsDialog}
onAcknowledge={handleAcknowledgeTerms}
/>
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe"> <Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
<TabsContainerWrapper> <TabsContainerWrapper>
<Tabs <Tabs
@ -459,7 +503,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
<Grid item xs={12}> <Grid item xs={12}>
<Box p={4}> <Box p={4}>
<InstallationSearch <InstallationSearch
installations={sodiohomeInstallations} installations={installations}
product={props.product}
/> />
</Box> </Box>
</Grid> </Grid>
@ -472,7 +517,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
path={'*'} path={'*'}
element={ element={
<Navigate <Navigate
to={routes.sodiohome_installations + routes.list} to={baseRoute + routes.list}
></Navigate> ></Navigate>
} }
></Route> ></Route>
@ -481,9 +526,12 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
</Card> </Card>
</Container> </Container>
</> </>
) : sodiohomeInstallations.length === 1 ? ( ) : installations.length === 1 ? (
<> <>
{' '} <AcknowledgementDialog
open={showTermsDialog}
onAcknowledge={handleAcknowledgeTerms}
/>
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe"> <Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
<TabsContainerWrapper> <TabsContainerWrapper>
<Tabs <Tabs
@ -523,7 +571,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
<Grid item xs={12}> <Grid item xs={12}>
<Box p={4}> <Box p={4}>
<SodioHomeInstallation <SodioHomeInstallation
current_installation={sodiohomeInstallations[0]} current_installation={installations[0]}
type="installation" type="installation"
></SodioHomeInstallation> ></SodioHomeInstallation>
</Box> </Box>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useRef, useState } from 'react';
import { import {
Avatar, Avatar,
Box, Box,
@ -6,15 +6,19 @@ import {
Card, Card,
CardContent, CardContent,
CardHeader, CardHeader,
Chip,
Divider, Divider,
LinearProgress,
TextField, TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from '@mui/icons-material/Person';
import SmartToyIcon from '@mui/icons-material/SmartToy'; import SmartToyIcon from '@mui/icons-material/SmartToy';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes'; import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
import DocumentList from 'src/components/DocumentList';
interface CommentThreadProps { interface CommentThreadProps {
ticketId: number; ticketId: number;
@ -31,21 +35,67 @@ function CommentThread({
}: CommentThreadProps) { }: CommentThreadProps) {
const [body, setBody] = useState(''); const [body, setBody] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
const MAX_FILE_SIZE = 25 * 1024 * 1024;
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
for (let i = 0; i < files.length; i++) {
if (!ALLOWED_TYPES.includes(files[i].type) || files[i].size > MAX_FILE_SIZE) return;
}
setSelectedFiles((prev) => [...prev, ...Array.from(files)]);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const sorted = [...comments].sort( const sorted = [...comments].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
); );
const handleSubmit = () => { const handleSubmit = async () => {
if (!body.trim()) return; if (!body.trim() && selectedFiles.length === 0) return;
setSubmitting(true); setSubmitting(true);
axiosConfig
.post('/AddTicketComment', { ticketId, body }) try {
.then(() => { let commentId: number | undefined;
if (body.trim()) {
const res = await axiosConfig.post('/AddTicketComment', { ticketId, body });
commentId = res.data?.id;
}
if (selectedFiles.length > 0) {
setUploading(true);
for (const file of selectedFiles) {
const formData = new FormData();
formData.append('file', file);
try {
await axiosConfig.post('/UploadDocument', formData, {
params: {
scope: 0,
ticketId,
ticketCommentId: commentId
},
headers: { 'Content-Type': 'multipart/form-data' }
});
} catch (err: any) {
console.warn(`[Documents] Upload failed for ${file.name}:`, err?.response?.data || err?.message);
}
}
setUploading(false);
}
setBody(''); setBody('');
setSelectedFiles([]);
setRefreshKey((k) => k + 1);
onCommentAdded(); onCommentAdded();
}) } finally {
.finally(() => setSubmitting(false)); setSubmitting(false);
}
}; };
return ( return (
@ -100,6 +150,7 @@ function CommentThread({
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}> <Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{comment.body} {comment.body}
</Typography> </Typography>
<DocumentList ticketCommentId={comment.id} refreshKey={refreshKey} />
</Box> </Box>
</Box> </Box>
); );
@ -107,6 +158,7 @@ function CommentThread({
<Divider /> <Divider />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
<TextField <TextField
size="small" size="small"
@ -118,15 +170,46 @@ function CommentThread({
value={body} value={body}
onChange={(e) => setBody(e.target.value)} onChange={(e) => setBody(e.target.value)}
/> />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, alignSelf: 'flex-end' }}>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
multiple
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Button
variant="outlined"
size="small"
onClick={() => fileInputRef.current?.click()}
disabled={submitting || uploading}
>
<AttachFileIcon fontSize="small" />
</Button>
<Button <Button
variant="contained" variant="contained"
onClick={handleSubmit} onClick={handleSubmit}
disabled={submitting || !body.trim()} disabled={submitting || uploading || (!body.trim() && selectedFiles.length === 0)}
sx={{ alignSelf: 'flex-end' }}
> >
<FormattedMessage id="addComment" defaultMessage="Add" /> <FormattedMessage id="addComment" defaultMessage="Add" />
</Button> </Button>
</Box> </Box>
</Box>
{selectedFiles.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selectedFiles.map((f, i) => (
<Chip
key={i}
label={f.name}
size="small"
onDelete={() => setSelectedFiles((prev) => prev.filter((_, idx) => idx !== i))}
/>
))}
</Box>
)}
{uploading && <LinearProgress />}
</Box>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -1,8 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Alert, Alert,
Autocomplete, Autocomplete,
Box,
Button, Button,
Chip,
CircularProgress, CircularProgress,
Dialog, Dialog,
DialogActions, DialogActions,
@ -10,10 +12,13 @@ import {
DialogTitle, DialogTitle,
FormControl, FormControl,
InputLabel, InputLabel,
LinearProgress,
MenuItem, MenuItem,
Select, Select,
TextField TextField,
Typography
} from '@mui/material'; } from '@mui/material';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
import { import {
@ -37,7 +42,8 @@ const productOptions = [
{ value: 1, label: 'Salidomo' }, { value: 1, label: 'Salidomo' },
{ value: 2, label: 'Sodistore Home' }, { value: 2, label: 'Sodistore Home' },
{ value: 3, label: 'Sodistore Max' }, { value: 3, label: 'Sodistore Max' },
{ value: 4, label: 'Sodistore Grid' } { value: 4, label: 'Sodistore Grid' },
{ value: 5, label: 'Sodistore Pro' }
]; ];
const deviceOptionsByProduct: Record<number, { value: number; label: string }[]> = { const deviceOptionsByProduct: Record<number, { value: number; label: string }[]> = {
@ -47,7 +53,7 @@ const deviceOptionsByProduct: Record<number, { value: number; label: string }[]>
], ],
2: [ 2: [
{ value: 3, label: 'Growatt' }, { value: 3, label: 'Growatt' },
{ value: 4, label: 'Sinexcel' } { value: 4, label: 'inesco 12K - WR Hybrid' }
] ]
}; };
@ -75,6 +81,38 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
const [error, setError] = useState(''); const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// File attachments
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
const MAX_FILE_SIZE = 25 * 1024 * 1024;
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!ALLOWED_TYPES.includes(file.type)) {
setError(`Invalid file type: ${file.name}`);
return;
}
if (file.size > MAX_FILE_SIZE) {
setError(`File too large: ${file.name} (max 25 MB)`);
return;
}
}
setError('');
setSelectedFiles((prev) => [...prev, ...Array.from(files)]);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const removeFile = (index: number) => {
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
};
// Custom "Other" fields // Custom "Other" fields
const [customSubCategory, setCustomSubCategory] = useState(''); const [customSubCategory, setCustomSubCategory] = useState('');
const [customCategory, setCustomCategory] = useState(''); const [customCategory, setCustomCategory] = useState('');
@ -200,16 +238,17 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
setDescription(''); setDescription('');
setCustomSubCategory(''); setCustomSubCategory('');
setCustomCategory(''); setCustomCategory('');
setSelectedFiles([]);
setError(''); setError('');
}; };
const handleSubmit = () => { const handleSubmit = async () => {
if (!subject.trim()) return; if (!subject.trim()) return;
setSubmitting(true); setSubmitting(true);
setError(''); setError('');
axiosConfig try {
.post('/CreateTicket', { const res = await axiosConfig.post('/CreateTicket', {
subject, subject,
description, description,
installationId: selectedInstallation?.id ?? null, installationId: selectedInstallation?.id ?? null,
@ -218,14 +257,40 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
subCategory: isOtherCategory ? 0 : subCategory, subCategory: isOtherCategory ? 0 : subCategory,
customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null, customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null,
customCategory: isOtherCategory ? customCategory || null : null customCategory: isOtherCategory ? customCategory || null : null
}) });
.then(() => {
const newTicketId = res.data?.id;
// Upload attached files if any
if (selectedFiles.length > 0 && newTicketId) {
setUploading(true);
for (const file of selectedFiles) {
const formData = new FormData();
formData.append('file', file);
try {
await axiosConfig.post('/UploadDocument', formData, {
params: { scope: 0, ticketId: newTicketId },
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total));
}
});
} catch (err: any) {
console.warn(`[Documents] Upload failed for ${file.name}:`, err?.response?.data || err?.message);
}
}
setUploading(false);
setUploadProgress(0);
}
resetForm(); resetForm();
onCreated(); onCreated();
onClose(); onClose();
}) } catch {
.catch(() => setError('Failed to create ticket.')) setError('Failed to create ticket.');
.finally(() => setSubmitting(false)); } finally {
setSubmitting(false);
}
}; };
const availableSubCategories = subCategoriesByCategory[category] ?? []; const availableSubCategories = subCategoriesByCategory[category] ?? [];
@ -441,6 +506,44 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
fullWidth fullWidth
margin="dense" margin="dense"
/> />
{/* File attachments */}
<Box sx={{ mt: 1 }}>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
multiple
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Button
variant="outlined"
size="small"
startIcon={<AttachFileIcon />}
onClick={() => fileInputRef.current?.click()}
disabled={submitting || uploading}
>
<FormattedMessage id="attachFiles" defaultMessage="Attach Files" />
</Button>
{selectedFiles.length > 0 && (
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selectedFiles.map((f, i) => (
<Chip
key={i}
label={f.name}
size="small"
onDelete={() => removeFile(i)}
/>
))}
</Box>
)}
{uploading && (
<Box sx={{ mt: 1 }}>
<LinearProgress variant="determinate" value={uploadProgress} />
</Box>
)}
</Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose}> <Button onClick={onClose}>

View File

@ -50,6 +50,8 @@ import StatusChip from './StatusChip';
import AiDiagnosisPanel from './AiDiagnosisPanel'; import AiDiagnosisPanel from './AiDiagnosisPanel';
import CommentThread from './CommentThread'; import CommentThread from './CommentThread';
import TimelinePanel from './TimelinePanel'; import TimelinePanel from './TimelinePanel';
import FileUploadButton from 'src/components/FileUploadButton';
import DocumentList from 'src/components/DocumentList';
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = { const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' }, [TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
@ -87,6 +89,7 @@ function TicketDetailPage() {
const [editingDescription, setEditingDescription] = useState(false); const [editingDescription, setEditingDescription] = useState(false);
const [savingDescription, setSavingDescription] = useState(false); const [savingDescription, setSavingDescription] = useState(false);
const [descriptionSaved, setDescriptionSaved] = useState(false); const [descriptionSaved, setDescriptionSaved] = useState(false);
const [docRefreshKey, setDocRefreshKey] = useState(0);
// Custom "Other" editing state // Custom "Other" editing state
const [editCustomSub, setEditCustomSub] = useState(''); const [editCustomSub, setEditCustomSub] = useState('');
@ -381,6 +384,21 @@ function TicketDetailPage() {
)} )}
</Typography> </Typography>
)} )}
<Box sx={{ mt: 2 }}>
<DocumentList
ticketId={ticket.id}
refreshKey={docRefreshKey}
canDelete={true}
/>
<Box sx={{ mt: 1 }}>
<FileUploadButton
scope={0}
ticketId={ticket.id}
onUploaded={() => setDocRefreshKey((k) => k + 1)}
/>
</Box>
</Box>
</CardContent> </CardContent>
</Card> </Card>
@ -742,7 +760,8 @@ function TicketDetailPage() {
1: routes.salidomo_installations, 1: routes.salidomo_installations,
2: routes.sodiohome_installations, 2: routes.sodiohome_installations,
3: routes.sodistore_installations, 3: routes.sodistore_installations,
4: routes.sodistoregrid_installations 4: routes.sodistoregrid_installations,
5: routes.sodistorepro_installations
}; };
const prefix = productRoutes[detail.installationProduct] ?? routes.installations; const prefix = productRoutes[detail.installationProduct] ?? routes.installations;
navigate( navigate(

View File

@ -60,6 +60,10 @@ function CustomTreeItem(props: CustomTreeItemProps) {
? routes.salidomo_installations ? routes.salidomo_installations
: installation.product == 2 : installation.product == 2
? routes.sodiohome_installations ? routes.sodiohome_installations
: installation.product == 4
? routes.sodistoregrid_installations
: installation.product == 5
? routes.sodistorepro_installations
: routes.sodistore_installations; : routes.sodistore_installations;
let folder_path = let folder_path =
@ -69,6 +73,10 @@ function CustomTreeItem(props: CustomTreeItemProps) {
? routes.salidomo_installations ? routes.salidomo_installations
: product == 2 : product == 2
? routes.sodiohome_installations ? routes.sodiohome_installations
: product == 4
? routes.sodistoregrid_installations
: product == 5
? routes.sodistorepro_installations
: routes.sodistore_installations; : routes.sodistore_installations;
if (installation.type != 'Folder') { if (installation.type != 'Folder') {
@ -209,6 +217,10 @@ function CustomTreeItem(props: CustomTreeItemProps) {
currentLocation.pathname === routes.installations + routes.tree || currentLocation.pathname === routes.installations + routes.tree ||
currentLocation.pathname === currentLocation.pathname ===
routes.sodiohome_installations + routes.tree || routes.sodiohome_installations + routes.tree ||
currentLocation.pathname ===
routes.sodistoregrid_installations + routes.tree ||
currentLocation.pathname ===
routes.sodistorepro_installations + routes.tree ||
currentLocation.pathname.includes('folder') currentLocation.pathname.includes('folder')
? 'block' ? 'block'
: 'none', : 'none',

View File

@ -59,17 +59,18 @@ function TreeInformation(props: TreeInformationProps) {
fetchAllInstallations fetchAllInstallations
} = installationContext; } = installationContext;
const [product, setProduct] = useState('Salimax'); const [product, setProduct] = useState('SodistoreHome');
const handleChangeInstallationChoice = (e) => { const handleChangeInstallationChoice = (e) => {
setProduct(e.target.value); // Directly update the product state setProduct(e.target.value); // Directly update the product state
}; };
const ProductTypes = ['Salimax', 'Salidomo', 'SodistoreHome', 'SodistoreMax', 'SodistoreGrid']; const ProductTypes = ['SodistoreHome', 'SodistorePro', 'SodistoreGrid', 'SodistoreMax', 'Salimax', 'Salidomo'];
const ProductDisplayNames: Record<string, string> = { const ProductDisplayNames: Record<string, string> = {
'SodistoreHome': 'Sodistore Home', 'SodistoreHome': 'Sodistore Home',
'SodistoreMax': 'Sodistore Max', 'SodistoreMax': 'Sodistore Max',
'SodistoreGrid': 'Sodistore Grid' 'SodistoreGrid': 'Sodistore Grid',
'SodistorePro': 'Sodistore Pro'
}; };
const isMobile = window.innerWidth <= 1490; const isMobile = window.innerWidth <= 1490;
@ -345,11 +346,12 @@ function TreeInformation(props: TreeInformationProps) {
/> />
)} )}
{openModalInstallation && product == 'SodistoreHome' && ( {openModalInstallation && (product == 'SodistoreHome' || product == 'SodistorePro') && (
<SodiostorehomeInstallationForm <SodiostorehomeInstallationForm
cancel={handleFormCancel} cancel={handleFormCancel}
submit={handleInstallationFormSubmit} submit={handleInstallationFormSubmit}
parentid={props.folder.id} parentid={props.folder.id}
product={product == 'SodistorePro' ? 5 : undefined}
/> />
)} )}

View File

@ -1,6 +1,7 @@
import React, { useCallback, useContext, useEffect, useState } from 'react'; import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { import {
Alert, Alert,
Autocomplete,
Box, Box,
CircularProgress, CircularProgress,
FormControl, FormControl,
@ -10,6 +11,7 @@ import {
Modal, Modal,
Select, Select,
TextField, TextField,
Typography,
useTheme useTheme
} from '@mui/material'; } from '@mui/material';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
@ -20,6 +22,16 @@ import { TokenContext } from 'src/contexts/tokenContext';
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes'; import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
const PRODUCT_GROUP_ORDER: number[] = [2, 5, 4, 3, 0, 1];
const PRODUCT_NAMES: Record<number, string> = {
0: 'Salimax',
1: 'Salidomo',
2: 'Sodistore Home',
3: 'Sodistore Max',
4: 'Sodistore Grid',
5: 'Sodistore Pro'
};
interface userFormProps { interface userFormProps {
cancel: () => void; cancel: () => void;
submit: () => void; submit: () => void;
@ -32,7 +44,6 @@ function userForm(props: userFormProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [errormessage, setErrorMessage] = useState(intl.formatMessage({ id: 'errorOccured' })); const [errormessage, setErrorMessage] = useState(intl.formatMessage({ id: 'errorOccured' }));
const [openInstallation, setOpenInstallation] = useState(false);
const [openFolder, setOpenFolder] = useState(false); const [openFolder, setOpenFolder] = useState(false);
const [formValues, setFormValues] = useState<Partial<InnovEnergyUser>>({ const [formValues, setFormValues] = useState<Partial<InnovEnergyUser>>({
name: '', name: '',
@ -41,9 +52,7 @@ function userForm(props: userFormProps) {
}); });
const requiredFields = ['name', 'email']; const requiredFields = ['name', 'email'];
const [selectedFolderNames, setSelectedFolderNames] = useState<string[]>([]); const [selectedFolderNames, setSelectedFolderNames] = useState<string[]>([]);
const [selectedInstallationNames, setSelectedInstallationNames] = useState< const [selectedInstallations, setSelectedInstallations] = useState<I_Installation[]>([]);
string[]
>([]);
const UserTypes = ['Client', 'Partner', 'Admin']; const UserTypes = ['Client', 'Partner', 'Admin'];
@ -72,22 +81,14 @@ function userForm(props: userFormProps) {
setLoading(true); setLoading(true);
try { try {
// fetch product 0 const products = [0, 1, 2, 3, 4, 5];
const res0 = await axiosConfig.get( const responses = await Promise.all(
`/GetAllInstallationsFromProduct?product=0` products.map((p) => axiosConfig.get(`/GetAllInstallationsFromProduct?product=${p}`))
); );
const installations0 = res0.data; const combined = responses.flatMap((res, idx) =>
res.data.map((inst: I_Installation) => ({ ...inst, product: products[idx] }))
// fetch product 1
const res1 = await axiosConfig.get(
`/GetAllInstallationsFromProduct?product=3`
); );
const installations1 = res1.data;
// aggregate
const combined = [...installations0, ...installations1];
// update
setInstallations(combined); setInstallations(combined);
} catch (err) { } catch (err) {
if (err.response && err.response.status === 401) { if (err.response && err.response.status === 401) {
@ -98,6 +99,15 @@ function userForm(props: userFormProps) {
} }
}, [setInstallations]); }, [setInstallations]);
const sortedInstallations = useMemo(() => {
const orderMap = new Map(PRODUCT_GROUP_ORDER.map((p, i) => [p, i]));
return [...installations].sort((a, b) => {
const oa = orderMap.get(a.product) ?? 99;
const ob = orderMap.get(b.product) ?? 99;
return oa !== ob ? oa - ob : a.name.localeCompare(b.name);
});
}, [installations]);
useEffect(() => { useEffect(() => {
fetchFolders(); fetchFolders();
fetchInstallations(); fetchInstallations();
@ -114,10 +124,6 @@ function userForm(props: userFormProps) {
setSelectedFolderNames(event.target.value); setSelectedFolderNames(event.target.value);
}; };
const handleInstallationChange = (event) => {
setSelectedInstallationNames(event.target.value);
};
const isMobile = window.innerWidth <= 1490; const isMobile = window.innerWidth <= 1490;
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
@ -151,11 +157,7 @@ function userForm(props: userFormProps) {
}); });
} }
for (const installationName of selectedInstallationNames) { for (const installation of selectedInstallations) {
const installation = installations.find(
(installation) => installation.name === installationName
);
await axiosConfig await axiosConfig
.post( .post(
`/GrantUserAccessToInstallation?UserId=${res.data.id}&InstallationId=${installation.id}` `/GrantUserAccessToInstallation?UserId=${res.data.id}&InstallationId=${installation.id}`
@ -205,14 +207,6 @@ function userForm(props: userFormProps) {
}); });
}; };
const handleOpenInstallation = () => {
setOpenInstallation(true);
};
const handleCloseInstallation = () => {
setOpenInstallation(false);
};
const handleOpenFolder = () => { const handleOpenFolder = () => {
setOpenFolder(true); setOpenFolder(true);
}; };
@ -343,55 +337,43 @@ function userForm(props: userFormProps) {
</div> </div>
<div> <div>
<FormControl fullWidth sx={{ marginTop: 2, width: 390 }}> <Autocomplete<I_Installation, true, false, false>
<InputLabel
sx={{
fontSize: 14,
backgroundColor: 'white'
}}
>
<FormattedMessage
id="grantAccessToInstallations"
defaultMessage="Grant access to installations"
/>
</InputLabel>
<Select
multiple multiple
value={selectedInstallationNames} options={sortedInstallations}
onChange={handleInstallationChange} groupBy={(option) => PRODUCT_NAMES[option.product] || 'Unknown'}
open={openInstallation} getOptionLabel={(option) => option.name}
onClose={handleCloseInstallation} value={selectedInstallations}
onOpen={handleOpenInstallation} onChange={(_event, newValue) => setSelectedInstallations(newValue)}
renderValue={(selected) => ( isOptionEqualToValue={(option, value) => option.id === value.id}
<div> renderGroup={(params) => (
{selected.map((installation) => ( <li key={params.key}>
<span key={installation}>{installation}, </span> <Typography
))}
</div>
)}
>
{installations.map((installation) => (
<MenuItem key={installation.id} value={installation.name}>
{installation.name}
</MenuItem>
))}
<Button
sx={{ sx={{
marginLeft: '150px', fontWeight: 'bold',
marginTop: '10px', fontSize: 13,
backgroundColor: theme.colors.primary.main, padding: '4px 16px',
color: 'white', backgroundColor: theme.colors.alpha.black[5],
'&:hover': { color: theme.colors.alpha.black[70]
backgroundColor: theme.colors.primary.dark
},
padding: '6px 8px'
}} }}
onClick={handleCloseInstallation}
> >
<FormattedMessage id="submit" defaultMessage="Submit" /> {params.group}
</Button> </Typography>
</Select> <ul style={{ padding: 0 }}>{params.children}</ul>
</FormControl> </li>
)}
renderInput={(params) => (
<TextField
{...params}
label={intl.formatMessage({ id: 'grantAccessToInstallations' })}
placeholder={intl.formatMessage({ id: 'searchInstallations' })}
InputLabelProps={{
...params.InputLabelProps,
sx: { fontSize: 14, backgroundColor: 'white' }
}}
/>
)}
sx={{ mt: 1 }}
/>
</div> </div>
<div> <div>

View File

@ -115,7 +115,7 @@ function Status500() {
<Container maxWidth="sm"> <Container maxWidth="sm">
<Box textAlign="center"> <Box textAlign="center">
<TypographyPrimary variant="h1" sx={{ my: 2 }}> <TypographyPrimary variant="h1" sx={{ my: 2 }}>
inesco Energy{' '} inesco energy{' '}
</TypographyPrimary> </TypographyPrimary>
<TypographySecondary <TypographySecondary
variant="h4" variant="h4"

View File

@ -34,6 +34,9 @@ const InstallationsContextProvider = ({
const [sodistoreGridInstallations, setSodistoreGridInstallations] = useState< const [sodistoreGridInstallations, setSodistoreGridInstallations] = useState<
I_Installation[] I_Installation[]
>([]); >([]);
const [sodistoreProInstallations, setSodistoreProInstallations] = useState<
I_Installation[]
>([]);
const [foldersAndInstallations, setFoldersAndInstallations] = useState([]); const [foldersAndInstallations, setFoldersAndInstallations] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
@ -105,10 +108,24 @@ const InstallationsContextProvider = ({
} }
); );
const updatedSodistorePro = sodistoreProInstallations.map(
(installation) => {
const update = pendingUpdates.current[installation.id];
return update
? {
...installation,
status: update.status,
testingMode: update.testingMode
}
: installation;
}
);
setSalidomoInstallations(updatedSalidomo); setSalidomoInstallations(updatedSalidomo);
setSalimax_Or_Sodistore_Installations(updatedSalimax); setSalimax_Or_Sodistore_Installations(updatedSalimax);
setSodiohomeInstallations(updatedSodiohome); setSodiohomeInstallations(updatedSodiohome);
setSodistoreGridInstallations(updatedSodistoreGrid); setSodistoreGridInstallations(updatedSodistoreGrid);
setSodistoreProInstallations(updatedSodistorePro);
// Clear the pending updates after applying // Clear the pending updates after applying
pendingUpdates.current = {}; pendingUpdates.current = {};
@ -116,7 +133,8 @@ const InstallationsContextProvider = ({
salidomoInstallations, salidomoInstallations,
salimax_or_sodistore_Installations, salimax_or_sodistore_Installations,
sodiohomeInstallations, sodiohomeInstallations,
sodistoreGridInstallations sodistoreGridInstallations,
sodistoreProInstallations
]); ]);
useEffect(() => { useEffect(() => {
@ -193,6 +211,8 @@ const InstallationsContextProvider = ({
if (product === 2) { if (product === 2) {
setSodiohomeInstallations(res.data); setSodiohomeInstallations(res.data);
} else if (product === 5) {
setSodistoreProInstallations(res.data);
} else if (product === 1) { } else if (product === 1) {
setSalidomoInstallations(res.data); setSalidomoInstallations(res.data);
} else if (product === 0 || product === 3) { } else if (product === 0 || product === 3) {
@ -418,6 +438,7 @@ const InstallationsContextProvider = ({
salidomoInstallations, salidomoInstallations,
sodiohomeInstallations, sodiohomeInstallations,
sodistoreGridInstallations, sodistoreGridInstallations,
sodistoreProInstallations,
foldersAndInstallations, foldersAndInstallations,
fetchAllInstallations, fetchAllInstallations,
fetchAllFoldersAndInstallations, fetchAllFoldersAndInstallations,
@ -445,6 +466,7 @@ const InstallationsContextProvider = ({
salidomoInstallations, salidomoInstallations,
sodiohomeInstallations, sodiohomeInstallations,
sodistoreGridInstallations, sodistoreGridInstallations,
sodistoreProInstallations,
foldersAndInstallations, foldersAndInstallations,
loading, loading,
error, error,

View File

@ -10,11 +10,13 @@ interface ProductIdContextType {
accessToSodiohome: boolean; accessToSodiohome: boolean;
accessToSodistore: boolean; accessToSodistore: boolean;
accessToSodistoreGrid: boolean; accessToSodistoreGrid: boolean;
accessToSodistorePro: boolean;
setAccessToSalimax: (access: boolean) => void; setAccessToSalimax: (access: boolean) => void;
setAccessToSalidomo: (access: boolean) => void; setAccessToSalidomo: (access: boolean) => void;
setAccessToSodiohome: (access: boolean) => void; setAccessToSodiohome: (access: boolean) => void;
setAccessToSodistore: (access: boolean) => void; setAccessToSodistore: (access: boolean) => void;
setAccessToSodistoreGrid: (access: boolean) => void; setAccessToSodistoreGrid: (access: boolean) => void;
setAccessToSodistorePro: (access: boolean) => void;
} }
// Create the context. // Create the context.
@ -49,6 +51,10 @@ export const ProductIdContextProvider = ({
const storedValue = localStorage.getItem('accessToSodistoreGrid'); const storedValue = localStorage.getItem('accessToSodistoreGrid');
return storedValue === 'true'; return storedValue === 'true';
}); });
const [accessToSodistorePro, setAccessToSodistorePro] = useState(() => {
const storedValue = localStorage.getItem('accessToSodistorePro');
return storedValue === 'true';
});
// const [product, setProduct] = useState(location.includes('salidomo') ? 1 : 0); // const [product, setProduct] = useState(location.includes('salidomo') ? 1 : 0);
// const [product, setProduct] = useState<number>( // const [product, setProduct] = useState<number>(
// productMapping[Object.keys(productMapping).find((key) => location.includes(key)) || ''] || -1 // productMapping[Object.keys(productMapping).find((key) => location.includes(key)) || ''] || -1
@ -56,6 +62,8 @@ export const ProductIdContextProvider = ({
const [product, setProduct] = useState<number>(() => { const [product, setProduct] = useState<number>(() => {
if (location.includes('salidomo')) { if (location.includes('salidomo')) {
return 1; return 1;
} else if (location.includes('sodistorepro')) {
return 5;
} else if (location.includes('sodiohome')) { } else if (location.includes('sodiohome')) {
return 2; return 2;
} else if (location.includes('sodistoregrid')) { } else if (location.includes('sodistoregrid')) {
@ -92,6 +100,10 @@ export const ProductIdContextProvider = ({
setAccessToSodistoreGrid(access); setAccessToSodistoreGrid(access);
localStorage.setItem('accessToSodistoreGrid', JSON.stringify(access)); localStorage.setItem('accessToSodistoreGrid', JSON.stringify(access));
}; };
const changeAccessSodistorePro = (access: boolean) => {
setAccessToSodistorePro(access);
localStorage.setItem('accessToSodistorePro', JSON.stringify(access));
};
return ( return (
<ProductIdContext.Provider <ProductIdContext.Provider
@ -103,11 +115,13 @@ export const ProductIdContextProvider = ({
accessToSodiohome, accessToSodiohome,
accessToSodistore, accessToSodistore,
accessToSodistoreGrid, accessToSodistoreGrid,
accessToSodistorePro,
setAccessToSalimax: changeAccessSalimax, setAccessToSalimax: changeAccessSalimax,
setAccessToSalidomo: changeAccessSalidomo, setAccessToSalidomo: changeAccessSalidomo,
setAccessToSodiohome: changeAccessSodiohome, setAccessToSodiohome: changeAccessSodiohome,
setAccessToSodistore: changeAccessSodistore, setAccessToSodistore: changeAccessSodistore,
setAccessToSodistoreGrid: changeAccessSodistoreGrid setAccessToSodistoreGrid: changeAccessSodistoreGrid,
setAccessToSodistorePro: changeAccessSodistorePro
}} }}
> >
{children} {children}

View File

@ -30,6 +30,7 @@ export interface overviewInterface {
overview: chartInfoInterface; overview: chartInfoInterface;
ACLoad: chartInfoInterface; ACLoad: chartInfoInterface;
DCLoad: chartInfoInterface; DCLoad: chartInfoInterface;
batteryVoltage: chartInfoInterface;
} }
export interface chartAggregatedDataInterface { export interface chartAggregatedDataInterface {
@ -53,6 +54,7 @@ export interface chartDataInterface {
dcBusVoltage: { name: string; data: number[] }; dcBusVoltage: { name: string; data: number[] };
ACLoad: { name: string; data: number[] }; ACLoad: { name: string; data: number[] };
DCLoad: { name: string; data: number[] }; DCLoad: { name: string; data: number[] };
batteryVoltage: { name: string; data: number[] };
} }
export interface BatteryDataInterface { export interface BatteryDataInterface {
@ -86,7 +88,7 @@ export const transformInputToBatteryViewDataJson = async (
}> => { }> => {
const prefixes = ['', 'k', 'M', 'G', 'T']; const prefixes = ['', 'k', 'M', 'G', 'T'];
const MAX_NUMBER = 9999999; const MAX_NUMBER = 9999999;
const isSodioHome = product === 2; const isSodioHome = product === 2 || product === 5;
const categories = isSodioHome const categories = isSodioHome
? ['Soc', 'Power', 'Voltage', 'Current', 'Soh'] ? ['Soc', 'Power', 'Voltage', 'Current', 'Soh']
: ['Soc', 'Temperature', 'Power', 'Voltage', 'Current']; : ['Soc', 'Temperature', 'Power', 'Voltage', 'Current'];
@ -169,7 +171,7 @@ export const transformInputToBatteryViewDataJson = async (
); );
const adjustedTimestamp = const adjustedTimestamp =
product == 0 || product == 2 || product == 3 || product == 4 product == 0 || product == 2 || product == 3 || product == 4 || product == 5
? new Date(timestampArray[i] * 1000) ? new Date(timestampArray[i] * 1000)
: new Date(timestampArray[i] * 100000); : new Date(timestampArray[i] * 100000);
//Timezone offset is negative, so we convert the timestamp to the current zone by subtracting the corresponding offset //Timezone offset is negative, so we convert the timestamp to the current zone by subtracting the corresponding offset
@ -226,26 +228,52 @@ export const transformInputToBatteryViewDataJson = async (
categories.forEach((category) => { categories.forEach((category) => {
pathsToSave.forEach((path) => { pathsToSave.forEach((path) => {
if (pathsToSave.indexOf(path) >= old_length) { if (pathsToSave.indexOf(path) >= old_length) {
chartData[category].data[path] = { name: path, data: [] }; const displayIndex = pathsToSave.indexOf(path);
chartData[category].data[path] = { name: 'Battery Cluster ' + (displayIndex + 1), data: [] };
} }
}); });
}); });
} }
// Map category names to InverterRecord field suffixes const hasDevices = !!inv?.Devices;
const categoryFieldMap = {
// Sinexcel field suffixes differ from Growatt for Voltage/Current
const categoryFieldMapGrowatt = {
Soc: 'Soc', Soc: 'Soc',
Power: 'Power', Power: 'Power',
Voltage: 'Voltage', Voltage: 'Voltage',
Current: 'Current', Current: 'Current',
Soh: 'Soh' Soh: 'Soh'
}; };
const categoryFieldMapSinexcel = {
Soc: 'Soc',
Power: 'Power',
Voltage: 'PackTotalVoltage',
Current: 'PackTotalCurrent',
Soh: 'Soh'
};
for (let j = 0; j < pathsToSave.length; j++) { for (let j = 0; j < pathsToSave.length; j++) {
const batteryIndex = j + 1; // Battery1, Battery2, ...
categories.forEach((category) => { categories.forEach((category) => {
const fieldName = `Battery${batteryIndex}${categoryFieldMap[category]}`; let value: number | undefined;
const value = inv[fieldName];
if (hasDevices) {
// Sinexcel: nested under Devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, ...
const deviceId = String(Math.floor(j / 2) + 1);
const bi = (j % 2) + 1;
const device = inv.Devices[deviceId];
const fieldName = `Battery${bi}${categoryFieldMapSinexcel[category]}`;
value = device?.[fieldName];
// Fallback for Soc
if ((value === undefined || value === null) && category === 'Soc') {
value = device?.[`Battery${bi}SocSecondvalue`];
}
} else {
// Growatt: flat Battery1Soc, Battery2Voltage, ...
const batteryIndex = j + 1;
const fieldName = `Battery${batteryIndex}${categoryFieldMapGrowatt[category]}`;
value = inv[fieldName];
}
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
if (value < chartOverview[category].min) { if (value < chartOverview[category].min) {
@ -393,7 +421,7 @@ export const transformInputToDailyDataJson = async (
// custom fallback logic to handle differences between Growatt and Sinexcel. // custom fallback logic to handle differences between Growatt and Sinexcel.
// Growatt has: Battery1AmbientTemperature, GridPower, PvPower // Growatt has: Battery1AmbientTemperature, GridPower, PvPower
// Sinexcel has: Battery1Temperature, TotalGridPower (meter may be offline), PvPower1-4 // Sinexcel has: Battery1Temperature, TotalGridPower (meter may be offline), PvPower1-4
const pathsToSearch = product == 2 const pathsToSearch = (product == 2 || product == 5)
? [ ? [
'SODIOHOME_SOC', 'SODIOHOME_SOC',
'SODIOHOME_TEMPERATURE', 'SODIOHOME_TEMPERATURE',
@ -402,7 +430,8 @@ export const transformInputToDailyDataJson = async (
'SODIOHOME_PV_POWER', 'SODIOHOME_PV_POWER',
null, // dcBusVoltage not available for SodioHome null, // dcBusVoltage not available for SodioHome
'SODIOHOME_CONSUMPTION', 'SODIOHOME_CONSUMPTION',
null // DCLoad not available for SodioHome null, // DCLoad not available for SodioHome
'SODIOHOME_BATTERY_VOLTAGE'
] ]
: [ : [
'Battery.Soc', 'Battery.Soc',
@ -412,7 +441,8 @@ export const transformInputToDailyDataJson = async (
'PvOnDc', 'PvOnDc',
'DcDc.Dc.Link.Voltage', 'DcDc.Dc.Link.Voltage',
'LoadOnAcGrid.Power.Active', 'LoadOnAcGrid.Power.Active',
'LoadOnDc.Power' 'LoadOnDc.Power',
null // batteryVoltage not available for Salimax
]; ];
const categories = [ const categories = [
'soc', 'soc',
@ -422,18 +452,20 @@ export const transformInputToDailyDataJson = async (
'pvProduction', 'pvProduction',
'dcBusVoltage', 'dcBusVoltage',
'ACLoad', 'ACLoad',
'DCLoad' 'DCLoad',
'batteryVoltage'
]; ];
const chartData: chartDataInterface = { const chartData: chartDataInterface = {
soc: { name: 'State Of Charge', data: [] }, soc: { name: 'Battery SOC', data: [] },
temperature: { name: 'Battery Temperature', data: [] }, temperature: { name: 'Battery Temperature', data: [] },
dcPower: { name: 'Battery Power', data: [] }, dcPower: { name: 'Battery Power', data: [] },
gridPower: { name: 'Grid Power', data: [] }, gridPower: { name: 'Grid Power', data: [] },
pvProduction: { name: 'Pv Production', data: [] }, pvProduction: { name: 'PV Power', data: [] },
dcBusVoltage: { name: 'DC Bus Voltage', data: [] }, dcBusVoltage: { name: 'DC Bus Voltage', data: [] },
ACLoad: { name: 'AC Load', data: [] }, ACLoad: { name: 'AC Load', data: [] },
DCLoad: { name: 'DC Load', data: [] } DCLoad: { name: 'DC Load', data: [] },
batteryVoltage: { name: 'Battery Voltage', data: [] }
}; };
const chartOverview: overviewInterface = { const chartOverview: overviewInterface = {
@ -446,7 +478,8 @@ export const transformInputToDailyDataJson = async (
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 }, dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
overview: { magnitude: 0, unit: '', min: 0, max: 0 }, overview: { magnitude: 0, unit: '', min: 0, max: 0 },
ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 }, ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 } DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
batteryVoltage: { magnitude: 0, unit: '', min: 0, max: 0 }
}; };
categories.forEach((category) => { categories.forEach((category) => {
@ -504,7 +537,8 @@ export const transformInputToDailyDataJson = async (
Object.keys(results[i]).length - 1 Object.keys(results[i]).length - 1
]; ];
const result = results[i][timestamp]; const result = results[i][timestamp];
//console.log(result); const { Config: { S3: { Key: _k, Secret: _s, ...s3Rest } = {} as any, ...configRest } = {} as any, ...dataRest } = result || {};
console.log('Overview data:', { ...dataRest, Config: { ...configRest, S3: { ...s3Rest, Key: '***', Secret: '***' } } });
let category_index = 0; let category_index = 0;
// eslint-disable-next-line @typescript-eslint/no-loop-func // eslint-disable-next-line @typescript-eslint/no-loop-func
pathsToSearch.forEach((path) => { pathsToSearch.forEach((path) => {
@ -516,8 +550,8 @@ export const transformInputToDailyDataJson = async (
let value: number | undefined = undefined; let value: number | undefined = undefined;
if (product === 2) { if (product === 2 || product === 5) {
// SodioHome: use top-level aggregated values (Sinexcel multi-inverter) // SodioHome/SodistorePro: use top-level aggregated values (Sinexcel multi-inverter)
const inv = result?.InverterRecord; const inv = result?.InverterRecord;
if (inv) { if (inv) {
switch (category_index) { switch (category_index) {
@ -539,6 +573,9 @@ export const transformInputToDailyDataJson = async (
case 6: // consumption case 6: // consumption
value = inv.TotalLoadPower ?? inv.ConsumptionPower; value = inv.TotalLoadPower ?? inv.ConsumptionPower;
break; break;
case 8: // battery voltage
value = inv.AvgBatteryVoltage ?? inv.Battery1Voltage;
break;
} }
} }
} else if (category_index === 4) { } else if (category_index === 4) {
@ -609,20 +646,24 @@ export const transformInputToDailyDataJson = async (
'(' + prefixes[chartOverview['ACLoad'].magnitude] + 'W' + ')'; '(' + prefixes[chartOverview['ACLoad'].magnitude] + 'W' + ')';
chartOverview.DCLoad.unit = chartOverview.DCLoad.unit =
'(' + prefixes[chartOverview['DCLoad'].magnitude] + 'W' + ')'; '(' + prefixes[chartOverview['DCLoad'].magnitude] + 'W' + ')';
chartOverview.batteryVoltage.unit = '(V)';
chartOverview.overview = { chartOverview.overview = {
magnitude: Math.max( magnitude: Math.max(
chartOverview['gridPower'].magnitude, chartOverview['gridPower'].magnitude,
chartOverview['pvProduction'].magnitude chartOverview['pvProduction'].magnitude,
chartOverview['ACLoad'].magnitude
), ),
unit: '(kW)', unit: '(kW)',
min: Math.min( min: Math.min(
chartOverview['gridPower'].min, chartOverview['gridPower'].min,
chartOverview['pvProduction'].min chartOverview['pvProduction'].min,
chartOverview['ACLoad'].min
), ),
max: Math.max( max: Math.max(
chartOverview['gridPower'].max, chartOverview['gridPower'].max,
chartOverview['pvProduction'].max chartOverview['pvProduction'].max,
chartOverview['ACLoad'].max
) )
}; };
@ -647,13 +688,14 @@ const fetchJsonDataForOneTime = async (
res = await fetchDataJson(timestampToFetch, s3Credentials); res = await fetchDataJson(timestampToFetch, s3Credentials);
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) { if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
//console.log('Successfully fetched ' + timestampToFetch); console.log('Successfully fetched ' + timestampToFetch);
return res; return res;
} }
} catch (err) { } catch (err) {
console.error('Error fetching data:', err); console.error('Error fetching data:', err);
} }
} }
console.warn('Failed to fetch timestamp ' + startUnixTime.ticks);
return null; return null;
}; };
@ -719,7 +761,8 @@ export const transformInputToAggregatedDataJson = async (
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 }, dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
overview: { magnitude: 0, unit: '', min: 0, max: 0 }, overview: { magnitude: 0, unit: '', min: 0, max: 0 },
ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 }, ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 } DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
batteryVoltage: { magnitude: 0, unit: '', min: 0, max: 0 }
}; };
pathsToSearch.forEach((path) => { pathsToSearch.forEach((path) => {
@ -735,7 +778,7 @@ export const transformInputToAggregatedDataJson = async (
const timestampPromises = []; const timestampPromises = [];
while (currentDay.isBefore(end_date)) { while (currentDay.isBefore(end_date)) {
const dateFormat = product === 2 const dateFormat = (product === 2 || product === 5)
? currentDay.format('DDMMYYYY') ? currentDay.format('DDMMYYYY')
: currentDay.format('YYYY-MM-DD'); : currentDay.format('YYYY-MM-DD');
timestampPromises.push( timestampPromises.push(
@ -745,7 +788,7 @@ export const transformInputToAggregatedDataJson = async (
} }
const results = await Promise.all(timestampPromises); const results = await Promise.all(timestampPromises);
console.log("Fetched aggregated daily results:", results); console.log("Fetched aggregated daily results: [count=" + results.length + "]");
currentDay = start_date; currentDay = start_date;
for (let i = 0; i < results.length; i++) { for (let i = 0; i < results.length; i++) {

View File

@ -7,6 +7,13 @@ export interface I_Installation extends I_S3Credentials {
location: string; location: string;
region: string; region: string;
country: string; country: string;
street?: string;
postCode?: string;
city?: string;
canton?: string;
distributionPartner?: string;
inverterFirmwareVersion?: string;
batteryFirmwareVersion?: string;
installationName: string; installationName: string;
vpnIp: string; vpnIp: string;
orderNumbers: string[] | string; orderNumbers: string[] | string;
@ -21,6 +28,7 @@ export interface I_Installation extends I_S3Credentials {
pvStringsPerInverter: string; pvStringsPerInverter: string;
installationModel: string; installationModel: string;
externalEms: string; externalEms: string;
couplingType: string;
parentId: number; parentId: number;
s3WriteKey: string; s3WriteKey: string;
@ -31,6 +39,7 @@ export interface I_Installation extends I_S3Credentials {
status?: number; status?: number;
serialNumber?: string; serialNumber?: string;
networkProvider: string; networkProvider: string;
email: string;
} }
export interface I_Folder { export interface I_Folder {

View File

@ -10,6 +10,7 @@ export type InnovEnergyUser = {
type: string; type: string;
folderIds?: number[]; folderIds?: number[];
mustResetPassword: boolean; mustResetPassword: boolean;
acknowledgedTermsVersion?: number;
}; };
export interface I_UserWithInheritedAccess { export interface I_UserWithInheritedAccess {

View File

@ -6,7 +6,15 @@
"alarms": "Alarme", "alarms": "Alarme",
"applyChanges": "Änderungen speichern", "applyChanges": "Änderungen speichern",
"country": "Land", "country": "Land",
"street": "Strasse",
"postCode": "PLZ",
"city": "Ort",
"canton": "Kanton",
"distributionPartner": "Vertriebspartner",
"inverterFirmwareVersion": "Wechselrichter-Firmware-Version",
"batteryFirmwareVersion": "Batterie-Firmware-Version",
"networkProvider": "Netzbetreiber", "networkProvider": "Netzbetreiber",
"emailAddress": "E-Mail-Adresse",
"createNewFolder": "Neuer Ordner", "createNewFolder": "Neuer Ordner",
"createNewUser": "Neuer Benutzer", "createNewUser": "Neuer Benutzer",
"customerName": "Kundenname", "customerName": "Kundenname",
@ -80,6 +88,9 @@
"emsOther": "Andere", "emsOther": "Andere",
"generalInfo": "Allgemeine Informationen", "generalInfo": "Allgemeine Informationen",
"installationSetup": "Installationseinrichtung", "installationSetup": "Installationseinrichtung",
"couplingType": "AC/DC-Kopplung",
"couplingAC": "AC-gekoppelt",
"couplingDC": "DC-gekoppelt",
"selectModel": "Modell auswählen...", "selectModel": "Modell auswählen...",
"inverterN": "Wechselrichter {n}", "inverterN": "Wechselrichter {n}",
"clusterN": "Cluster {n}", "clusterN": "Cluster {n}",
@ -109,6 +120,7 @@
"deleteFolder": "Ordner löschen", "deleteFolder": "Ordner löschen",
"grantAccessToFolders": "Zugriff auf Ordner gewähren", "grantAccessToFolders": "Zugriff auf Ordner gewähren",
"grantAccessToInstallations": "Zugriff auf Installationen gewähren", "grantAccessToInstallations": "Zugriff auf Installationen gewähren",
"searchInstallations": "Installationen suchen...",
"cannotloadloggingdata": "Log Daten können nicht geladen werden", "cannotloadloggingdata": "Log Daten können nicht geladen werden",
"grantedAccessToUsers": "Den Benutzern wurde den Zugriff gewährt", "grantedAccessToUsers": "Den Benutzern wurde den Zugriff gewährt",
"unableToGrantAccess": "Der Zugriff kann nicht gewährt werden", "unableToGrantAccess": "Der Zugriff kann nicht gewährt werden",
@ -121,7 +133,6 @@
"reportTitle": "Wöchentlicher Leistungsbericht", "reportTitle": "Wöchentlicher Leistungsbericht",
"weeklyInsights": "Wöchentliche Einblicke", "weeklyInsights": "Wöchentliche Einblicke",
"missingDaysWarning": "Daten verfügbar für {available}/{expected} Tage. Fehlend: {dates}", "missingDaysWarning": "Daten verfügbar für {available}/{expected} Tage. Fehlend: {dates}",
"weeklySavings": "Ihre Einsparungen diese Woche",
"solarEnergyUsed": "Energie gespart", "solarEnergyUsed": "Energie gespart",
"solarStayedHome": "Solar + Batterie, nicht vom Netz", "solarStayedHome": "Solar + Batterie, nicht vom Netz",
"daysOfYourUsage": "Tage Ihres Verbrauchs", "daysOfYourUsage": "Tage Ihres Verbrauchs",
@ -129,10 +140,8 @@
"atCHFRate": "bei 0,39 CHF/kWh Ø", "atCHFRate": "bei 0,39 CHF/kWh Ø",
"solarCoverage": "Energieunabhängigkeit", "solarCoverage": "Energieunabhängigkeit",
"fromSolarSub": "aus eigenem Solar + Batterie System", "fromSolarSub": "aus eigenem Solar + Batterie System",
"avgDailyConsumption": "Ø Tagesverbrauch",
"batteryEfficiency": "Batterieeffizienz", "batteryEfficiency": "Batterieeffizienz",
"batteryEffSub": "Entladung vs. Ladung", "batteryEffSub": "Entladung vs. Ladung",
"weeklySummary": "Wöchentliche Zusammenfassung",
"metric": "Kennzahl", "metric": "Kennzahl",
"thisWeek": "Diese Woche", "thisWeek": "Diese Woche",
"change": "Änderung", "change": "Änderung",
@ -142,11 +151,12 @@
"gridExport": "Netzeinspeisung", "gridExport": "Netzeinspeisung",
"batteryInOut": "Batterie Laden / Entladen", "batteryInOut": "Batterie Laden / Entladen",
"dailyBreakdown": "Tägliche Aufschlüsselung", "dailyBreakdown": "Tägliche Aufschlüsselung",
"prevWeek": "(Vorwoche)",
"sendReport": "Bericht senden", "sendReport": "Bericht senden",
"generatingReport": "Wochenbericht wird erstellt...", "generatingReport": "Wochenbericht wird erstellt...",
"reportSentTo": "Bericht gesendet an {email}", "reportSentTo": "Bericht gesendet an {email}",
"reportSendError": "Senden fehlgeschlagen. Bitte überprüfen Sie die E-Mail-Adresse und versuchen Sie es erneut.", "reportSendError": "Senden fehlgeschlagen. Bitte überprüfen Sie die E-Mail-Adresse und versuchen Sie es erneut.",
"refreshReport": "Bericht aktualisieren",
"refreshing": "Aktualisierung...",
"ok": "Ok", "ok": "Ok",
"grantedAccessToUser": "Zugriff für Benutzer {name} gewährt", "grantedAccessToUser": "Zugriff für Benutzer {name} gewährt",
"proceed": "Fortfahren", "proceed": "Fortfahren",
@ -165,7 +175,7 @@
"dailySummary": "Tagesübersicht", "dailySummary": "Tagesübersicht",
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.", "noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.", "noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
"currentWeekHint": "Aktuelle Woche (Mogestern)", "currentWeekHint": "Letzte 7 Tage",
"intradayChart": "Tagesverlauf Energiefluss", "intradayChart": "Tagesverlauf Energiefluss",
"batteryPower": "Batterieleistung", "batteryPower": "Batterieleistung",
"batterySoCLabel": "Batterie SoC", "batterySoCLabel": "Batterie SoC",
@ -179,21 +189,20 @@
"yearlyReportTitle": "Jährlicher Leistungsbericht", "yearlyReportTitle": "Jährlicher Leistungsbericht",
"monthlyInsights": "Monatliche Einblicke", "monthlyInsights": "Monatliche Einblicke",
"yearlyInsights": "Jährliche Einblicke", "yearlyInsights": "Jährliche Einblicke",
"monthlySavings": "Ihre Einsparungen diesen Monat",
"yearlySavings": "Ihre Einsparungen dieses Jahr",
"monthlySummary": "Monatliche Zusammenfassung",
"yearlySummary": "Jährliche Zusammenfassung",
"total": "Gesamt", "total": "Gesamt",
"weeksAggregated": "{count} Wochen aggregiert", "weeksAggregated": "{count} Wochen aggregiert",
"monthsAggregated": "{count} Monate aggregiert", "monthsAggregated": "{count} Monate aggregiert",
"noMonthlyData": "Noch keine monatlichen Berichte verfügbar. Wöchentliche Berichte erscheinen hier zur Aggregation.", "noMonthlyData": "Noch keine monatlichen Berichte verfügbar. Wöchentliche Berichte erscheinen hier zur Aggregation.",
"noYearlyData": "Noch keine jährlichen Berichte verfügbar. Monatliche Berichte erscheinen hier zur Aggregation.", "noYearlyData": "Noch keine jährlichen Berichte verfügbar. Monatliche Berichte erscheinen hier zur Aggregation.",
"availableForGeneration": "Zur Generierung verfügbar", "reportInProgress": "{month} (in Bearbeitung)",
"generateMonth": "{month} {year} generieren ({count} Wochen)", "daysOfTotal": "{available} von {total} Tagen",
"generateYear": "{year} generieren ({count} Monate)", "monthsOfTotal": "{available} von {total} Monaten",
"regenerateReport": "Neu generieren", "monthlyAutoNote": "Der endgültige Bericht wird automatisch am 1. des nächsten Monats erstellt.",
"generatingMonthly": "Wird generiert...", "yearlyAutoNote": "Der endgültige Bericht wird automatisch am 2. Januar erstellt.",
"generatingYearly": "Wird generiert...", "autoSendReports": "Berichte automatisch senden:",
"autoSendSaved": "Automatische Versandeinstellungen gespeichert.",
"autoSendSaveFailed": "Fehler beim Speichern der automatischen Versandeinstellungen.",
"autoSendNoEmail": "E-Mail-Adresse im Reiter Information eingeben, um den automatischen Versand zu aktivieren",
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats", "thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
"recentWeeklyReports": "Letzte Wochenberichte", "recentWeeklyReports": "Letzte Wochenberichte",
"ai_analyzing": "KI analysiert...", "ai_analyzing": "KI analysiert...",
@ -204,7 +213,7 @@
"demo_test_button": "KI-Diagnose", "demo_test_button": "KI-Diagnose",
"demo_hide_button": "KI-Diagnose ausblenden", "demo_hide_button": "KI-Diagnose ausblenden",
"demo_panel_title": "KI-Diagnose", "demo_panel_title": "KI-Diagnose",
"demo_custom_group": "Benutzerdefiniert (kann Mistral KI verwenden)", "demo_custom_group": "Benutzerdefiniert (kann KI verwenden)",
"demo_custom_option": "Benutzerdefinierten Alarm eingeben…", "demo_custom_option": "Benutzerdefinierten Alarm eingeben…",
"demo_custom_placeholder": "z.B. UnknownBatteryFault", "demo_custom_placeholder": "z.B. UnknownBatteryFault",
"demo_diagnose_button": "Diagnostizieren", "demo_diagnose_button": "Diagnostizieren",
@ -545,6 +554,7 @@
"tourConfigurationContent": "Geräteeinstellungen für diese Installation anzeigen und ändern.", "tourConfigurationContent": "Geräteeinstellungen für diese Installation anzeigen und ändern.",
"tourHistoryTitle": "Verlauf", "tourHistoryTitle": "Verlauf",
"tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert.", "tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert.",
"tourInstallationTicketsContent": "Support-Tickets für diese Installation anzeigen und verwalten — Probleme melden, Fortschritt verfolgen und KI-gestützte Diagnosen einsehen.",
"tickets": "Tickets", "tickets": "Tickets",
"createTicket": "Ticket erstellen", "createTicket": "Ticket erstellen",
"subject": "Betreff", "subject": "Betreff",
@ -643,5 +653,38 @@
"timelineAiDiagnosisCompletedDesc": "KI-Diagnose abgeschlossen.", "timelineAiDiagnosisCompletedDesc": "KI-Diagnose abgeschlossen.",
"timelineAiDiagnosisFailedDesc": "KI-Diagnose fehlgeschlagen.", "timelineAiDiagnosisFailedDesc": "KI-Diagnose fehlgeschlagen.",
"timelineEscalatedDesc": "Ticket eskaliert.", "timelineEscalatedDesc": "Ticket eskaliert.",
"timelineResolutionAddedDesc": "Lösung hinzugefügt von {name}." "timelineResolutionAddedDesc": "Lösung hinzugefügt von {name}.",
"terms_dialog_title": "Willkommen bei inesco energy",
"terms_data_heading": "Ihre Daten",
"terms_data_body": "Ihre Installationsdaten werden sicher in der Schweiz gespeichert. Wir geben Ihre Daten nicht an Dritte weiter.",
"terms_ai_heading": "KI-gestützte Einblicke",
"terms_ai_body": "Wir nutzen einen in der EU gehosteten KI-Dienst, um Diagnosen und Einblicke für Ihre Installationen bereitzustellen. KI-generierte Ergebnisse sind Empfehlungen und sollten von qualifiziertem Personal überprüft werden.",
"terms_cookies_heading": "Browser-Speicher",
"terms_cookies_body": "Diese Plattform speichert Anmelde- und Einstellungsdaten in Ihrem Browser, um Sie angemeldet zu halten und Ihre Sprachauswahl zu speichern.",
"terms_acknowledge_button": "Ich verstehe",
"privacy_menu_item": "Daten & Datenschutz",
"privacy_dialog_title": "Daten & Datenschutz",
"privacy_data_heading": "Wo werden meine Daten gespeichert?",
"privacy_data_body": "Ihre Installationsdaten werden auf Servern in der Schweiz gespeichert. Nur autorisiertes Personal von inesco energy kann zu Supportzwecken auf Ihre Daten zugreifen.",
"privacy_ai_heading": "Wie wird KI eingesetzt?",
"privacy_ai_body": "Wir nutzen einen in der Europäischen Union gehosteten KI-Dienst, um Ihre Installationsdaten zu analysieren und diagnostische Einblicke zu liefern. Die KI verarbeitet technische Daten wie Batteriemesswerte und Fehlercodes. KI-Empfehlungen sollten stets von qualifiziertem Personal überprüft werden.",
"privacy_browser_heading": "Was speichert mein Browser?",
"privacy_browser_body": "Ihr Browser speichert Ihre Anmeldesitzung, um Sie angemeldet zu halten, sowie Ihre Sprach- und Designeinstellungen. Es werden keine Tracking- oder Werbe-Cookies verwendet.",
"privacy_access_heading": "Wer hat Zugriff auf meine Daten?",
"privacy_access_body": "Ihre Daten werden nicht an Dritte weitergegeben. Sie werden ausschliesslich für den Betrieb der Plattform und zur Bereitstellung von Einblicken in Ihre Installationen verwendet.",
"privacy_close_button": "Schliessen",
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Anzahl der Wechselrichter",
"documentsTab": "Dokumente",
"documentsHint": "Akzeptierte Formate: JPEG, PNG, GIF, WebP, PDF. Maximale Dateigrösse: 25 MB.",
"attachFiles": "Dateien anhängen",
"attachments": "Anhänge",
"documents": "Dokumente",
"installationDocuments": "Installationsdokumente",
"uploadDocument": "Dokument hochladen",
"noDocuments": "Noch keine Dokumente.",
"fileTooLarge": "Datei überschreitet die maximale Grösse von 25 MB.",
"invalidFileType": "Ungültiger Dateityp.",
"uploadFailed": "Hochladen fehlgeschlagen.",
"uploadSuccess": "Hochladen erfolgreich."
} }

View File

@ -2,7 +2,15 @@
"allInstallations": "All installations", "allInstallations": "All installations",
"applyChanges": "Apply changes", "applyChanges": "Apply changes",
"country": "Country", "country": "Country",
"street": "Street",
"postCode": "Postcode",
"city": "City",
"canton": "Canton",
"distributionPartner": "Distribution Partner",
"inverterFirmwareVersion": "Inverter Firmware Version",
"batteryFirmwareVersion": "Battery Firmware Version",
"networkProvider": "Network Provider", "networkProvider": "Network Provider",
"emailAddress": "Email Address",
"customerName": "Customer name", "customerName": "Customer name",
"english": "English", "english": "English",
"german": "German", "german": "German",
@ -62,6 +70,9 @@
"emsOther": "Other", "emsOther": "Other",
"generalInfo": "General Info", "generalInfo": "General Info",
"installationSetup": "Installation Setup", "installationSetup": "Installation Setup",
"couplingType": "AC/DC Coupling",
"couplingAC": "AC-coupled",
"couplingDC": "DC-coupled",
"selectModel": "Select model...", "selectModel": "Select model...",
"inverterN": "Inverter {n}", "inverterN": "Inverter {n}",
"clusterN": "Cluster {n}", "clusterN": "Cluster {n}",
@ -91,6 +102,7 @@
"deleteFolder": "Delete Folder", "deleteFolder": "Delete Folder",
"grantAccessToFolders": "Grant Access to Folders", "grantAccessToFolders": "Grant Access to Folders",
"grantAccessToInstallations": "Grant Access to Installations", "grantAccessToInstallations": "Grant Access to Installations",
"searchInstallations": "Search installations...",
"cannotloadloggingdata": "Cannot load logging data", "cannotloadloggingdata": "Cannot load logging data",
"grantedAccessToUsers": "Granted access to users: ", "grantedAccessToUsers": "Granted access to users: ",
"unableToGrantAccess": "Unable to grant access to: ", "unableToGrantAccess": "Unable to grant access to: ",
@ -103,7 +115,6 @@
"reportTitle": "Weekly Performance Report", "reportTitle": "Weekly Performance Report",
"weeklyInsights": "Weekly Insights", "weeklyInsights": "Weekly Insights",
"missingDaysWarning": "Data available for {available}/{expected} days. Missing: {dates}", "missingDaysWarning": "Data available for {available}/{expected} days. Missing: {dates}",
"weeklySavings": "Your Savings This Week",
"solarEnergyUsed": "Energy Saved", "solarEnergyUsed": "Energy Saved",
"solarStayedHome": "solar + battery, not bought from grid", "solarStayedHome": "solar + battery, not bought from grid",
"daysOfYourUsage": "days of your usage", "daysOfYourUsage": "days of your usage",
@ -111,10 +122,8 @@
"atCHFRate": "at 0.39 CHF/kWh avg.", "atCHFRate": "at 0.39 CHF/kWh avg.",
"solarCoverage": "Energy Independence", "solarCoverage": "Energy Independence",
"fromSolarSub": "from your own solar + battery system", "fromSolarSub": "from your own solar + battery system",
"avgDailyConsumption": "Avg Daily Consumption",
"batteryEfficiency": "Battery Efficiency", "batteryEfficiency": "Battery Efficiency",
"batteryEffSub": "discharge vs charge", "batteryEffSub": "discharge vs charge",
"weeklySummary": "Weekly Summary",
"metric": "Metric", "metric": "Metric",
"thisWeek": "This Week", "thisWeek": "This Week",
"change": "Change", "change": "Change",
@ -124,11 +133,12 @@
"gridExport": "Grid Export", "gridExport": "Grid Export",
"batteryInOut": "Battery Charge / Discharge", "batteryInOut": "Battery Charge / Discharge",
"dailyBreakdown": "Daily Breakdown", "dailyBreakdown": "Daily Breakdown",
"prevWeek": "(prev week)",
"sendReport": "Send Report", "sendReport": "Send Report",
"generatingReport": "Generating weekly report...", "generatingReport": "Generating weekly report...",
"reportSentTo": "Report sent to {email}", "reportSentTo": "Report sent to {email}",
"reportSendError": "Failed to send. Please check the email address and try again.", "reportSendError": "Failed to send. Please check the email address and try again.",
"refreshReport": "Refresh Report",
"refreshing": "Refreshing...",
"ok": "Ok", "ok": "Ok",
"grantedAccessToUser": "Granted access to user {name}", "grantedAccessToUser": "Granted access to user {name}",
"proceed": "Proceed", "proceed": "Proceed",
@ -147,7 +157,7 @@
"dailySummary": "Daily Summary", "dailySummary": "Daily Summary",
"noDataForDate": "No data available for the selected date.", "noDataForDate": "No data available for the selected date.",
"noHourlyData": "Hourly data not available for this day.", "noHourlyData": "Hourly data not available for this day.",
"currentWeekHint": "Current week (Monyesterday)", "currentWeekHint": "Last 7 days",
"intradayChart": "Intraday Power Flow", "intradayChart": "Intraday Power Flow",
"batteryPower": "Battery Power", "batteryPower": "Battery Power",
"batterySoCLabel": "Battery SoC", "batterySoCLabel": "Battery SoC",
@ -161,21 +171,20 @@
"yearlyReportTitle": "Annual Performance Report", "yearlyReportTitle": "Annual Performance Report",
"monthlyInsights": "Monthly Insights", "monthlyInsights": "Monthly Insights",
"yearlyInsights": "Annual Insights", "yearlyInsights": "Annual Insights",
"monthlySavings": "Your Savings This Month",
"yearlySavings": "Your Savings This Year",
"monthlySummary": "Monthly Summary",
"yearlySummary": "Annual Summary",
"total": "Total", "total": "Total",
"weeksAggregated": "{count} weeks aggregated", "weeksAggregated": "{count} weeks aggregated",
"monthsAggregated": "{count} months aggregated", "monthsAggregated": "{count} months aggregated",
"noMonthlyData": "No monthly reports available yet. Weekly reports will appear here for aggregation once generated.", "noMonthlyData": "No monthly reports available yet. Weekly reports will appear here for aggregation once generated.",
"noYearlyData": "No yearly reports available yet. Monthly reports will appear here for aggregation once generated.", "noYearlyData": "No yearly reports available yet. Monthly reports will appear here for aggregation once generated.",
"availableForGeneration": "Available for Generation", "reportInProgress": "{month} (in progress)",
"generateMonth": "Generate {month} {year} ({count} weeks)", "daysOfTotal": "{available} of {total} days",
"generateYear": "Generate {year} ({count} months)", "monthsOfTotal": "{available} of {total} months",
"regenerateReport": "Regenerate", "monthlyAutoNote": "Final report will be automatically generated on the 1st of next month.",
"generatingMonthly": "Generating...", "yearlyAutoNote": "Final report will be automatically generated on January 2nd.",
"generatingYearly": "Generating...", "autoSendReports": "Auto-send reports:",
"autoSendSaved": "Auto-send preferences saved.",
"autoSendSaveFailed": "Failed to save auto-send preferences.",
"autoSendNoEmail": "Set email address in Information tab to enable auto-send",
"thisMonthWeeklyReports": "This Month's Weekly Reports", "thisMonthWeeklyReports": "This Month's Weekly Reports",
"recentWeeklyReports": "Recent Weekly Reports", "recentWeeklyReports": "Recent Weekly Reports",
"ai_analyzing": "AI is analyzing...", "ai_analyzing": "AI is analyzing...",
@ -186,7 +195,7 @@
"demo_test_button": "AI Diagnosis", "demo_test_button": "AI Diagnosis",
"demo_hide_button": "Hide AI Diagnosis", "demo_hide_button": "Hide AI Diagnosis",
"demo_panel_title": "AI Diagnosis", "demo_panel_title": "AI Diagnosis",
"demo_custom_group": "Custom (may use Mistral AI)", "demo_custom_group": "Custom (may use AI)",
"demo_custom_option": "Type custom alarm below…", "demo_custom_option": "Type custom alarm below…",
"demo_custom_placeholder": "e.g. UnknownBatteryFault", "demo_custom_placeholder": "e.g. UnknownBatteryFault",
"demo_diagnose_button": "Diagnose", "demo_diagnose_button": "Diagnose",
@ -293,6 +302,7 @@
"tourConfigurationContent": "View and modify device settings for this installation.", "tourConfigurationContent": "View and modify device settings for this installation.",
"tourHistoryTitle": "History", "tourHistoryTitle": "History",
"tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when.", "tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when.",
"tourInstallationTicketsContent": "View and manage support tickets for this installation — report issues, track progress, and see AI-powered diagnostics.",
"tickets": "Tickets", "tickets": "Tickets",
"createTicket": "Create Ticket", "createTicket": "Create Ticket",
"subject": "Subject", "subject": "Subject",
@ -391,5 +401,38 @@
"timelineAiDiagnosisCompletedDesc": "AI diagnosis completed.", "timelineAiDiagnosisCompletedDesc": "AI diagnosis completed.",
"timelineAiDiagnosisFailedDesc": "AI diagnosis failed.", "timelineAiDiagnosisFailedDesc": "AI diagnosis failed.",
"timelineEscalatedDesc": "Ticket escalated.", "timelineEscalatedDesc": "Ticket escalated.",
"timelineResolutionAddedDesc": "Resolution added by {name}." "timelineResolutionAddedDesc": "Resolution added by {name}.",
"terms_dialog_title": "Welcome to inesco energy",
"terms_data_heading": "Your Data",
"terms_data_body": "Your installation data is securely stored in Switzerland. We do not share your data with third parties.",
"terms_ai_heading": "AI-Powered Insights",
"terms_ai_body": "We use an AI service hosted in the EU to provide diagnostics and insights for your installations. AI-generated results are recommendations and should be verified by qualified personnel.",
"terms_cookies_heading": "Browser Storage",
"terms_cookies_body": "This platform stores login and preference settings in your browser to keep you signed in and remember your language choice.",
"terms_acknowledge_button": "I understand",
"privacy_menu_item": "Data & Privacy",
"privacy_dialog_title": "Data & Privacy",
"privacy_data_heading": "Where is my data stored?",
"privacy_data_body": "Your installation data is stored on servers in Switzerland. Only authorized inesco energy personnel can access your data for support purposes.",
"privacy_ai_heading": "How is AI used?",
"privacy_ai_body": "We use an AI service hosted in the European Union to analyze your installation data and provide diagnostic insights. The AI processes technical data such as battery readings and error codes. AI recommendations should always be verified by qualified personnel.",
"privacy_browser_heading": "What does my browser store?",
"privacy_browser_body": "Your browser stores your login session to keep you signed in, and your language and theme preferences. No tracking or advertising cookies are used.",
"privacy_access_heading": "Who has access to my data?",
"privacy_access_body": "Your data is not shared with third parties. It is used solely to operate the platform and provide you with insights about your installations.",
"privacy_close_button": "Close",
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Number of Inverters",
"documentsTab": "Documents",
"documentsHint": "Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB.",
"attachFiles": "Attach Files",
"attachments": "Attachments",
"documents": "Documents",
"installationDocuments": "Installation Documents",
"uploadDocument": "Upload Document",
"noDocuments": "No documents yet.",
"fileTooLarge": "File exceeds maximum size of 25 MB.",
"invalidFileType": "Invalid file type.",
"uploadFailed": "Upload failed.",
"uploadSuccess": "Upload successful."
} }

View File

@ -4,7 +4,15 @@
"alarms": "Alarmes", "alarms": "Alarmes",
"applyChanges": "Appliquer", "applyChanges": "Appliquer",
"country": "Pays", "country": "Pays",
"street": "Rue",
"postCode": "Code postal",
"city": "Ville",
"canton": "Canton",
"distributionPartner": "Partenaire de distribution",
"inverterFirmwareVersion": "Version firmware onduleur",
"batteryFirmwareVersion": "Version firmware batterie",
"networkProvider": "Gestionnaire de réseau", "networkProvider": "Gestionnaire de réseau",
"emailAddress": "Adresse e-mail",
"createNewFolder": "Nouveau dossier", "createNewFolder": "Nouveau dossier",
"createNewUser": "Nouvel utilisateur", "createNewUser": "Nouvel utilisateur",
"customerName": "Nom du client", "customerName": "Nom du client",
@ -74,6 +82,9 @@
"emsOther": "Autre", "emsOther": "Autre",
"generalInfo": "Informations générales", "generalInfo": "Informations générales",
"installationSetup": "Configuration de l'installation", "installationSetup": "Configuration de l'installation",
"couplingType": "Couplage AC/DC",
"couplingAC": "Couplage AC",
"couplingDC": "Couplage DC",
"selectModel": "Sélectionner le modèle...", "selectModel": "Sélectionner le modèle...",
"inverterN": "Onduleur {n}", "inverterN": "Onduleur {n}",
"clusterN": "Cluster {n}", "clusterN": "Cluster {n}",
@ -103,6 +114,7 @@
"deleteFolder": "Supprimer le dossier", "deleteFolder": "Supprimer le dossier",
"grantAccessToFolders": "Accorder l'accès aux dossiers", "grantAccessToFolders": "Accorder l'accès aux dossiers",
"grantAccessToInstallations": "Accorder l'accès aux installations", "grantAccessToInstallations": "Accorder l'accès aux installations",
"searchInstallations": "Rechercher des installations...",
"cannotloadloggingdata": "Impossible de charger les données de journalisation", "cannotloadloggingdata": "Impossible de charger les données de journalisation",
"grantedAccessToUsers": "Accès accordé aux utilisateurs", "grantedAccessToUsers": "Accès accordé aux utilisateurs",
"unableToGrantAccess": "Impossible d'accorder l'accès à", "unableToGrantAccess": "Impossible d'accorder l'accès à",
@ -115,7 +127,6 @@
"reportTitle": "Rapport de performance hebdomadaire", "reportTitle": "Rapport de performance hebdomadaire",
"weeklyInsights": "Aperçus hebdomadaires", "weeklyInsights": "Aperçus hebdomadaires",
"missingDaysWarning": "Données disponibles pour {available}/{expected} jours. Manquants : {dates}", "missingDaysWarning": "Données disponibles pour {available}/{expected} jours. Manquants : {dates}",
"weeklySavings": "Vos économies cette semaine",
"solarEnergyUsed": "Énergie économisée", "solarEnergyUsed": "Énergie économisée",
"solarStayedHome": "solaire + batterie, non achetée au réseau", "solarStayedHome": "solaire + batterie, non achetée au réseau",
"daysOfYourUsage": "jours de votre consommation", "daysOfYourUsage": "jours de votre consommation",
@ -123,10 +134,8 @@
"atCHFRate": "à 0,39 CHF/kWh moy.", "atCHFRate": "à 0,39 CHF/kWh moy.",
"solarCoverage": "Indépendance énergétique", "solarCoverage": "Indépendance énergétique",
"fromSolarSub": "de votre système solaire + batterie", "fromSolarSub": "de votre système solaire + batterie",
"avgDailyConsumption": "Conso. quotidienne moy.",
"batteryEfficiency": "Efficacité de la batterie", "batteryEfficiency": "Efficacité de la batterie",
"batteryEffSub": "décharge vs charge", "batteryEffSub": "décharge vs charge",
"weeklySummary": "Résumé hebdomadaire",
"metric": "Métrique", "metric": "Métrique",
"thisWeek": "Cette semaine", "thisWeek": "Cette semaine",
"change": "Variation", "change": "Variation",
@ -136,11 +145,12 @@
"gridExport": "Exportation réseau", "gridExport": "Exportation réseau",
"batteryInOut": "Batterie Charge / Décharge", "batteryInOut": "Batterie Charge / Décharge",
"dailyBreakdown": "Répartition quotidienne", "dailyBreakdown": "Répartition quotidienne",
"prevWeek": "(semaine précédente)",
"sendReport": "Envoyer le rapport", "sendReport": "Envoyer le rapport",
"generatingReport": "Génération du rapport hebdomadaire...", "generatingReport": "Génération du rapport hebdomadaire...",
"reportSentTo": "Rapport envoyé à {email}", "reportSentTo": "Rapport envoyé à {email}",
"reportSendError": "Échec de l'envoi. Veuillez vérifier l'adresse e-mail et réessayer.", "reportSendError": "Échec de l'envoi. Veuillez vérifier l'adresse e-mail et réessayer.",
"refreshReport": "Actualiser le rapport",
"refreshing": "Actualisation...",
"ok": "Ok", "ok": "Ok",
"grantedAccessToUser": "Accès accordé à l'utilisateur {name}", "grantedAccessToUser": "Accès accordé à l'utilisateur {name}",
"proceed": "Continuer", "proceed": "Continuer",
@ -159,7 +169,7 @@
"dailySummary": "Résumé du jour", "dailySummary": "Résumé du jour",
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.", "noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
"noHourlyData": "Données horaires non disponibles pour ce jour.", "noHourlyData": "Données horaires non disponibles pour ce jour.",
"currentWeekHint": "Semaine en cours (lunhier)", "currentWeekHint": "7 derniers jours",
"intradayChart": "Flux d'énergie journalier", "intradayChart": "Flux d'énergie journalier",
"batteryPower": "Puissance batterie", "batteryPower": "Puissance batterie",
"batterySoCLabel": "SoC batterie", "batterySoCLabel": "SoC batterie",
@ -173,21 +183,20 @@
"yearlyReportTitle": "Rapport de performance annuel", "yearlyReportTitle": "Rapport de performance annuel",
"monthlyInsights": "Aperçus mensuels", "monthlyInsights": "Aperçus mensuels",
"yearlyInsights": "Aperçus annuels", "yearlyInsights": "Aperçus annuels",
"monthlySavings": "Vos économies ce mois",
"yearlySavings": "Vos économies cette année",
"monthlySummary": "Résumé mensuel",
"yearlySummary": "Résumé annuel",
"total": "Total", "total": "Total",
"weeksAggregated": "{count} semaines agrégées", "weeksAggregated": "{count} semaines agrégées",
"monthsAggregated": "{count} mois agrégés", "monthsAggregated": "{count} mois agrégés",
"noMonthlyData": "Aucun rapport mensuel disponible. Les rapports hebdomadaires apparaîtront ici pour l'agrégation.", "noMonthlyData": "Aucun rapport mensuel disponible. Les rapports hebdomadaires apparaîtront ici pour l'agrégation.",
"noYearlyData": "Aucun rapport annuel disponible. Les rapports mensuels apparaîtront ici pour l'agrégation.", "noYearlyData": "Aucun rapport annuel disponible. Les rapports mensuels apparaîtront ici pour l'agrégation.",
"availableForGeneration": "Disponible pour génération", "reportInProgress": "{month} (en cours)",
"generateMonth": "Générer {month} {year} ({count} semaines)", "daysOfTotal": "{available} sur {total} jours",
"generateYear": "Générer {year} ({count} mois)", "monthsOfTotal": "{available} sur {total} mois",
"regenerateReport": "Régénérer", "monthlyAutoNote": "Le rapport final sera généré automatiquement le 1er du mois prochain.",
"generatingMonthly": "Génération en cours...", "yearlyAutoNote": "Le rapport final sera généré automatiquement le 2 janvier.",
"generatingYearly": "Génération en cours...", "autoSendReports": "Envoi automatique des rapports :",
"autoSendSaved": "Préférences d'envoi automatique enregistrées.",
"autoSendSaveFailed": "Échec de l'enregistrement des préférences d'envoi automatique.",
"autoSendNoEmail": "Définir l'adresse e-mail dans l'onglet Information pour activer l'envoi automatique",
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois", "thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
"recentWeeklyReports": "Derniers rapports hebdomadaires", "recentWeeklyReports": "Derniers rapports hebdomadaires",
"ai_analyzing": "L'IA analyse...", "ai_analyzing": "L'IA analyse...",
@ -198,7 +207,7 @@
"demo_test_button": "Diagnostic IA", "demo_test_button": "Diagnostic IA",
"demo_hide_button": "Masquer le diagnostic IA", "demo_hide_button": "Masquer le diagnostic IA",
"demo_panel_title": "Diagnostic IA", "demo_panel_title": "Diagnostic IA",
"demo_custom_group": "Personnalisé (peut utiliser Mistral IA)", "demo_custom_group": "Personnalisé (peut utiliser IA)",
"demo_custom_option": "Saisir une alarme personnalisée…", "demo_custom_option": "Saisir une alarme personnalisée…",
"demo_custom_placeholder": "ex. UnknownBatteryFault", "demo_custom_placeholder": "ex. UnknownBatteryFault",
"demo_diagnose_button": "Diagnostiquer", "demo_diagnose_button": "Diagnostiquer",
@ -545,6 +554,7 @@
"tourConfigurationContent": "Afficher et modifier les paramètres de l'appareil pour cette installation.", "tourConfigurationContent": "Afficher et modifier les paramètres de l'appareil pour cette installation.",
"tourHistoryTitle": "Historique", "tourHistoryTitle": "Historique",
"tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand.", "tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand.",
"tourInstallationTicketsContent": "Consultez et gérez les tickets de support pour cette installation — signalez des problèmes, suivez la progression et consultez les diagnostics IA.",
"tickets": "Tickets", "tickets": "Tickets",
"createTicket": "Créer un ticket", "createTicket": "Créer un ticket",
"subject": "Objet", "subject": "Objet",
@ -643,5 +653,38 @@
"timelineAiDiagnosisCompletedDesc": "Diagnostic IA terminé.", "timelineAiDiagnosisCompletedDesc": "Diagnostic IA terminé.",
"timelineAiDiagnosisFailedDesc": "Diagnostic IA échoué.", "timelineAiDiagnosisFailedDesc": "Diagnostic IA échoué.",
"timelineEscalatedDesc": "Ticket escaladé.", "timelineEscalatedDesc": "Ticket escaladé.",
"timelineResolutionAddedDesc": "Résolution ajoutée par {name}." "timelineResolutionAddedDesc": "Résolution ajoutée par {name}.",
"terms_dialog_title": "Bienvenue chez inesco energy",
"terms_data_heading": "Vos données",
"terms_data_body": "Les données de votre installation sont stockées en toute sécurité en Suisse. Nous ne partageons pas vos données avec des tiers.",
"terms_ai_heading": "Analyses basées sur l'IA",
"terms_ai_body": "Nous utilisons un service d'IA hébergé dans l'UE pour fournir des diagnostics et des analyses pour vos installations. Les résultats générés par l'IA sont des recommandations et doivent être vérifiés par du personnel qualifié.",
"terms_cookies_heading": "Stockage du navigateur",
"terms_cookies_body": "Cette plateforme enregistre vos paramètres de connexion et de préférences dans votre navigateur pour maintenir votre session et mémoriser votre choix de langue.",
"terms_acknowledge_button": "Je comprends",
"privacy_menu_item": "Données et confidentialité",
"privacy_dialog_title": "Données et confidentialité",
"privacy_data_heading": "Où sont stockées mes données ?",
"privacy_data_body": "Les données de votre installation sont stockées sur des serveurs en Suisse. Seul le personnel autorisé d'inesco energy peut accéder à vos données à des fins d'assistance.",
"privacy_ai_heading": "Comment l'IA est-elle utilisée ?",
"privacy_ai_body": "Nous utilisons un service d'IA hébergé dans l'Union européenne pour analyser les données de votre installation et fournir des informations diagnostiques. L'IA traite des données techniques telles que les relevés de batterie et les codes d'erreur. Les recommandations de l'IA doivent toujours être vérifiées par du personnel qualifié.",
"privacy_browser_heading": "Que stocke mon navigateur ?",
"privacy_browser_body": "Votre navigateur stocke votre session de connexion pour vous maintenir connecté, ainsi que vos préférences de langue et de thème. Aucun cookie de suivi ou publicitaire n'est utilisé.",
"privacy_access_heading": "Qui a accès à mes données ?",
"privacy_access_body": "Vos données ne sont pas partagées avec des tiers. Elles sont utilisées uniquement pour le fonctionnement de la plateforme et pour vous fournir des informations sur vos installations.",
"privacy_close_button": "Fermer",
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Nombre d'onduleurs",
"documentsTab": "Documents",
"documentsHint": "Formats acceptés : JPEG, PNG, GIF, WebP, PDF. Taille maximale : 25 Mo.",
"attachFiles": "Joindre des fichiers",
"attachments": "Pièces jointes",
"documents": "Documents",
"installationDocuments": "Documents d'installation",
"uploadDocument": "Télécharger un document",
"noDocuments": "Aucun document pour le moment.",
"fileTooLarge": "Le fichier dépasse la taille maximale de 25 Mo.",
"invalidFileType": "Type de fichier non valide.",
"uploadFailed": "Échec du téléchargement.",
"uploadSuccess": "Téléchargement réussi."
} }

View File

@ -2,7 +2,15 @@
"allInstallations": "Tutte le installazioni", "allInstallations": "Tutte le installazioni",
"applyChanges": "Applica modifiche", "applyChanges": "Applica modifiche",
"country": "Paese", "country": "Paese",
"street": "Via",
"postCode": "CAP",
"city": "Città",
"canton": "Cantone",
"distributionPartner": "Partner di distribuzione",
"inverterFirmwareVersion": "Versione firmware inverter",
"batteryFirmwareVersion": "Versione firmware batteria",
"networkProvider": "Gestore di rete", "networkProvider": "Gestore di rete",
"emailAddress": "Indirizzo e-mail",
"customerName": "Nome cliente", "customerName": "Nome cliente",
"english": "Inglese", "english": "Inglese",
"german": "Tedesco", "german": "Tedesco",
@ -62,6 +70,9 @@
"emsOther": "Altro", "emsOther": "Altro",
"generalInfo": "Informazioni generali", "generalInfo": "Informazioni generali",
"installationSetup": "Configurazione installazione", "installationSetup": "Configurazione installazione",
"couplingType": "Accoppiamento AC/DC",
"couplingAC": "Accoppiamento AC",
"couplingDC": "Accoppiamento DC",
"selectModel": "Seleziona modello...", "selectModel": "Seleziona modello...",
"inverterN": "Inverter {n}", "inverterN": "Inverter {n}",
"clusterN": "Cluster {n}", "clusterN": "Cluster {n}",
@ -91,6 +102,7 @@
"deleteFolder": "Elimina cartella", "deleteFolder": "Elimina cartella",
"grantAccessToFolders": "Concedi accesso alle cartelle", "grantAccessToFolders": "Concedi accesso alle cartelle",
"grantAccessToInstallations": "Concedi accesso alle installazioni", "grantAccessToInstallations": "Concedi accesso alle installazioni",
"searchInstallations": "Cerca installazioni...",
"cannotloadloggingdata": "Impossibile caricare i dati di registro", "cannotloadloggingdata": "Impossibile caricare i dati di registro",
"grantedAccessToUsers": "Accesso concesso agli utenti: ", "grantedAccessToUsers": "Accesso concesso agli utenti: ",
"unableToGrantAccess": "Impossibile concedere l'accesso a: ", "unableToGrantAccess": "Impossibile concedere l'accesso a: ",
@ -126,7 +138,6 @@
"reportTitle": "Rapporto settimanale sulle prestazioni", "reportTitle": "Rapporto settimanale sulle prestazioni",
"weeklyInsights": "Approfondimenti settimanali", "weeklyInsights": "Approfondimenti settimanali",
"missingDaysWarning": "Dati disponibili per {available}/{expected} giorni. Mancanti: {dates}", "missingDaysWarning": "Dati disponibili per {available}/{expected} giorni. Mancanti: {dates}",
"weeklySavings": "I tuoi risparmi questa settimana",
"solarEnergyUsed": "Energia risparmiata", "solarEnergyUsed": "Energia risparmiata",
"solarStayedHome": "solare + batteria, non acquistata dalla rete", "solarStayedHome": "solare + batteria, non acquistata dalla rete",
"daysOfYourUsage": "giorni del tuo consumo", "daysOfYourUsage": "giorni del tuo consumo",
@ -134,10 +145,8 @@
"atCHFRate": "a 0,39 CHF/kWh media", "atCHFRate": "a 0,39 CHF/kWh media",
"solarCoverage": "Indipendenza energetica", "solarCoverage": "Indipendenza energetica",
"fromSolarSub": "dal proprio impianto solare + batteria", "fromSolarSub": "dal proprio impianto solare + batteria",
"avgDailyConsumption": "Consumo medio giornaliero",
"batteryEfficiency": "Efficienza della batteria", "batteryEfficiency": "Efficienza della batteria",
"batteryEffSub": "scarica vs carica", "batteryEffSub": "scarica vs carica",
"weeklySummary": "Riepilogo settimanale",
"metric": "Metrica", "metric": "Metrica",
"thisWeek": "Questa settimana", "thisWeek": "Questa settimana",
"change": "Variazione", "change": "Variazione",
@ -147,11 +156,12 @@
"gridExport": "Esportazione rete", "gridExport": "Esportazione rete",
"batteryInOut": "Batteria Carica / Scarica", "batteryInOut": "Batteria Carica / Scarica",
"dailyBreakdown": "Ripartizione giornaliera", "dailyBreakdown": "Ripartizione giornaliera",
"prevWeek": "(settimana precedente)",
"sendReport": "Invia rapporto", "sendReport": "Invia rapporto",
"generatingReport": "Generazione del rapporto settimanale...", "generatingReport": "Generazione del rapporto settimanale...",
"reportSentTo": "Rapporto inviato a {email}", "reportSentTo": "Rapporto inviato a {email}",
"reportSendError": "Invio fallito. Verificare l'indirizzo e-mail e riprovare.", "reportSendError": "Invio fallito. Verificare l'indirizzo e-mail e riprovare.",
"refreshReport": "Aggiorna rapporto",
"refreshing": "Aggiornamento...",
"ok": "Ok", "ok": "Ok",
"grantedAccessToUser": "Accesso concesso all'utente {name}", "grantedAccessToUser": "Accesso concesso all'utente {name}",
"proceed": "Procedi", "proceed": "Procedi",
@ -170,7 +180,7 @@
"dailySummary": "Riepilogo del giorno", "dailySummary": "Riepilogo del giorno",
"noDataForDate": "Nessun dato disponibile per la data selezionata.", "noDataForDate": "Nessun dato disponibile per la data selezionata.",
"noHourlyData": "Dati orari non disponibili per questo giorno.", "noHourlyData": "Dati orari non disponibili per questo giorno.",
"currentWeekHint": "Settimana corrente (lunieri)", "currentWeekHint": "Ultimi 7 giorni",
"intradayChart": "Flusso energetico giornaliero", "intradayChart": "Flusso energetico giornaliero",
"batteryPower": "Potenza batteria", "batteryPower": "Potenza batteria",
"batterySoCLabel": "SoC batteria", "batterySoCLabel": "SoC batteria",
@ -184,21 +194,20 @@
"yearlyReportTitle": "Rapporto annuale sulle prestazioni", "yearlyReportTitle": "Rapporto annuale sulle prestazioni",
"monthlyInsights": "Approfondimenti mensili", "monthlyInsights": "Approfondimenti mensili",
"yearlyInsights": "Approfondimenti annuali", "yearlyInsights": "Approfondimenti annuali",
"monthlySavings": "I tuoi risparmi questo mese",
"yearlySavings": "I tuoi risparmi quest'anno",
"monthlySummary": "Riepilogo mensile",
"yearlySummary": "Riepilogo annuale",
"total": "Totale", "total": "Totale",
"weeksAggregated": "{count} settimane aggregate", "weeksAggregated": "{count} settimane aggregate",
"monthsAggregated": "{count} mesi aggregati", "monthsAggregated": "{count} mesi aggregati",
"noMonthlyData": "Nessun rapporto mensile ancora disponibile. I rapporti settimanali appariranno qui per l'aggregazione.", "noMonthlyData": "Nessun rapporto mensile ancora disponibile. I rapporti settimanali appariranno qui per l'aggregazione.",
"noYearlyData": "Nessun rapporto annuale ancora disponibile. I rapporti mensili appariranno qui per l'aggregazione.", "noYearlyData": "Nessun rapporto annuale ancora disponibile. I rapporti mensili appariranno qui per l'aggregazione.",
"availableForGeneration": "Disponibile per la generazione", "reportInProgress": "{month} (in corso)",
"generateMonth": "Genera {month} {year} ({count} settimane)", "daysOfTotal": "{available} di {total} giorni",
"generateYear": "Genera {year} ({count} mesi)", "monthsOfTotal": "{available} di {total} mesi",
"regenerateReport": "Rigenera", "monthlyAutoNote": "Il rapporto finale verrà generato automaticamente il 1° del mese prossimo.",
"generatingMonthly": "Generazione in corso...", "yearlyAutoNote": "Il rapporto finale verrà generato automaticamente il 2 gennaio.",
"generatingYearly": "Generazione in corso...", "autoSendReports": "Invio automatico rapporti:",
"autoSendSaved": "Preferenze di invio automatico salvate.",
"autoSendSaveFailed": "Impossibile salvare le preferenze di invio automatico.",
"autoSendNoEmail": "Impostare l'indirizzo e-mail nella scheda Informazioni per attivare l'invio automatico",
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese", "thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
"recentWeeklyReports": "Ultimi rapporti settimanali", "recentWeeklyReports": "Ultimi rapporti settimanali",
"ai_analyzing": "L'IA sta analizzando...", "ai_analyzing": "L'IA sta analizzando...",
@ -209,7 +218,7 @@
"demo_test_button": "Diagnosi IA", "demo_test_button": "Diagnosi IA",
"demo_hide_button": "Nascondi diagnosi IA", "demo_hide_button": "Nascondi diagnosi IA",
"demo_panel_title": "Diagnosi IA", "demo_panel_title": "Diagnosi IA",
"demo_custom_group": "Personalizzato (potrebbe usare Mistral IA)", "demo_custom_group": "Personalizzato (potrebbe usare IA)",
"demo_custom_option": "Inserisci allarme personalizzato…", "demo_custom_option": "Inserisci allarme personalizzato…",
"demo_custom_placeholder": "es. UnknownBatteryFault", "demo_custom_placeholder": "es. UnknownBatteryFault",
"demo_diagnose_button": "Diagnostica", "demo_diagnose_button": "Diagnostica",
@ -545,6 +554,7 @@
"tourConfigurationContent": "Visualizza e modifica le impostazioni del dispositivo per questa installazione.", "tourConfigurationContent": "Visualizza e modifica le impostazioni del dispositivo per questa installazione.",
"tourHistoryTitle": "Cronologia", "tourHistoryTitle": "Cronologia",
"tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando.", "tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando.",
"tourInstallationTicketsContent": "Visualizza e gestisci i ticket di supporto per questa installazione — segnala problemi, monitora i progressi e consulta le diagnosi IA.",
"tickets": "Ticket", "tickets": "Ticket",
"createTicket": "Crea ticket", "createTicket": "Crea ticket",
"subject": "Oggetto", "subject": "Oggetto",
@ -643,5 +653,38 @@
"timelineAiDiagnosisCompletedDesc": "Diagnosi IA completata.", "timelineAiDiagnosisCompletedDesc": "Diagnosi IA completata.",
"timelineAiDiagnosisFailedDesc": "Diagnosi IA fallita.", "timelineAiDiagnosisFailedDesc": "Diagnosi IA fallita.",
"timelineEscalatedDesc": "Ticket escalato.", "timelineEscalatedDesc": "Ticket escalato.",
"timelineResolutionAddedDesc": "Risoluzione aggiunta da {name}." "timelineResolutionAddedDesc": "Risoluzione aggiunta da {name}.",
"terms_dialog_title": "Benvenuto su inesco energy",
"terms_data_heading": "I tuoi dati",
"terms_data_body": "I dati della tua installazione sono archiviati in modo sicuro in Svizzera. Non condividiamo i tuoi dati con terze parti.",
"terms_ai_heading": "Analisi basate sull'IA",
"terms_ai_body": "Utilizziamo un servizio di IA ospitato nell'UE per fornire diagnostica e analisi per le tue installazioni. I risultati generati dall'IA sono raccomandazioni e devono essere verificati da personale qualificato.",
"terms_cookies_heading": "Archiviazione del browser",
"terms_cookies_body": "Questa piattaforma memorizza le impostazioni di accesso e le preferenze nel browser per mantenerti connesso e ricordare la tua scelta linguistica.",
"terms_acknowledge_button": "Ho capito",
"privacy_menu_item": "Dati e privacy",
"privacy_dialog_title": "Dati e privacy",
"privacy_data_heading": "Dove vengono archiviati i miei dati?",
"privacy_data_body": "I dati della tua installazione sono archiviati su server in Svizzera. Solo il personale autorizzato di inesco energy può accedere ai tuoi dati per scopi di assistenza.",
"privacy_ai_heading": "Come viene utilizzata l'IA?",
"privacy_ai_body": "Utilizziamo un servizio di IA ospitato nell'Unione Europea per analizzare i dati della tua installazione e fornire informazioni diagnostiche. L'IA elabora dati tecnici come le letture delle batterie e i codici di errore. Le raccomandazioni dell'IA devono sempre essere verificate da personale qualificato.",
"privacy_browser_heading": "Cosa memorizza il mio browser?",
"privacy_browser_body": "Il tuo browser memorizza la sessione di accesso per mantenerti connesso e le tue preferenze di lingua e tema. Non vengono utilizzati cookie di tracciamento o pubblicitari.",
"privacy_access_heading": "Chi ha accesso ai miei dati?",
"privacy_access_body": "I tuoi dati non vengono condivisi con terze parti. Vengono utilizzati esclusivamente per il funzionamento della piattaforma e per fornirti informazioni sulle tue installazioni.",
"privacy_close_button": "Chiudi",
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Numero di inverter",
"documentsTab": "Documenti",
"documentsHint": "Formati accettati: JPEG, PNG, GIF, WebP, PDF. Dimensione massima: 25 MB.",
"attachFiles": "Allega file",
"attachments": "Allegati",
"documents": "Documenti",
"installationDocuments": "Documenti dell'installazione",
"uploadDocument": "Carica documento",
"noDocuments": "Nessun documento ancora.",
"fileTooLarge": "Il file supera la dimensione massima di 25 MB.",
"invalidFileType": "Tipo di file non valido.",
"uploadFailed": "Caricamento fallito.",
"uploadSuccess": "Caricamento riuscito."
} }

View File

@ -9,9 +9,11 @@ import {
import React, { useContext, useRef, useState } from 'react'; import React, { useContext, useRef, useState } from 'react';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import ExpandMoreTwoToneIcon from '@mui/icons-material/ExpandMoreTwoTone'; import ExpandMoreTwoToneIcon from '@mui/icons-material/ExpandMoreTwoTone';
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
import { ThemeContext } from '../../../../theme/ThemeProvider'; import { ThemeContext } from '../../../../theme/ThemeProvider';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import '../../../../App.css'; import '../../../../App.css';
import DataPrivacyDialog from '../../../../components/DataPrivacyDialog';
interface HeaderButtonsProps { interface HeaderButtonsProps {
language: string; language: string;
@ -79,6 +81,7 @@ function HeaderMenu(props: HeaderButtonsProps) {
const setThemeName = themeContext; const setThemeName = themeContext;
const [darkState, setDarkState] = useState(false); const [darkState, setDarkState] = useState(false);
const [privacyOpen, setPrivacyOpen] = useState(false);
const handleThemeChange = () => { const handleThemeChange = () => {
setDarkState(!darkState); setDarkState(!darkState);
@ -132,6 +135,20 @@ function HeaderMenu(props: HeaderButtonsProps) {
} }
/> />
</ListItem> </ListItem>
<ListItem
classes={{ root: 'MuiListItem-indicators' }}
onClick={() => setPrivacyOpen(true)}
>
<ListItemText
primaryTypographyProps={{ noWrap: true }}
primary={
<Box display="flex" alignItems="center">
<ShieldOutlinedIcon fontSize="small" sx={{ mr: 0.5 }} />
<FormattedMessage id="privacy_menu_item" defaultMessage="Data & Privacy" />
</Box>
}
/>
</ListItem>
</List> </List>
</ListWrapper> </ListWrapper>
<div <div
@ -152,6 +169,10 @@ function HeaderMenu(props: HeaderButtonsProps) {
</MenuItem> </MenuItem>
</Menu> </Menu>
</div> </div>
<DataPrivacyDialog
open={privacyOpen}
onClose={() => setPrivacyOpen(false)}
/>
</div> </div>
); );
} }

View File

@ -170,7 +170,8 @@ function SidebarMenu() {
accessToSodistore, accessToSodistore,
accessToSalidomo, accessToSalidomo,
accessToSodiohome, accessToSodiohome,
accessToSodistoreGrid accessToSodistoreGrid,
accessToSodistorePro
} = useContext(ProductIdContext); } = useContext(ProductIdContext);
return ( return (
@ -185,37 +186,20 @@ function SidebarMenu() {
} }
> >
<SubMenuWrapper> <SubMenuWrapper>
{accessToSalimax && ( {accessToSodiohome && (
<List component="div"> <List component="div">
<ListItem component="div"> <ListItem component="div">
<Button <Button
disableRipple disableRipple
component={RouterLink} component={RouterLink}
onClick={closeSidebar} onClick={closeSidebar}
to="/installations" to="/sodiohome_installations"
startIcon={<BrightnessLowTwoToneIcon />}
>
<Box sx={{ marginTop: '3px' }}>
<FormattedMessage id="salimax" defaultMessage="Salimax" />
</Box>
</Button>
</ListItem>
</List>
)}
{accessToSodistore && (
<List component="div">
<ListItem component="div">
<Button
disableRipple
component={RouterLink}
onClick={closeSidebar}
to="/sodistore_installations"
startIcon={<BrightnessLowTwoToneIcon />} startIcon={<BrightnessLowTwoToneIcon />}
> >
<Box sx={{ marginTop: '3px' }}> <Box sx={{ marginTop: '3px' }}>
<FormattedMessage <FormattedMessage
id="sodistore" id="sodiohome"
defaultMessage="Sodistore Max" defaultMessage="Sodistore Home"
/> />
</Box> </Box>
</Button> </Button>
@ -223,20 +207,20 @@ function SidebarMenu() {
</List> </List>
)} )}
{accessToSalidomo && ( {accessToSodistorePro && (
<List component="div"> <List component="div">
<ListItem component="div"> <ListItem component="div">
<Button <Button
disableRipple disableRipple
component={RouterLink} component={RouterLink}
onClick={closeSidebar} onClick={closeSidebar}
to="/salidomo_installations" to="/sodistorepro_installations"
startIcon={<BrightnessLowTwoToneIcon />} startIcon={<BrightnessLowTwoToneIcon />}
> >
<Box sx={{ marginTop: '3px' }}> <Box sx={{ marginTop: '3px' }}>
<FormattedMessage <FormattedMessage
id="salidomo" id="sodistorepro"
defaultMessage="Salidomo" defaultMessage="Sodistore Pro"
/> />
</Box> </Box>
</Button> </Button>
@ -265,20 +249,59 @@ function SidebarMenu() {
</List> </List>
)} )}
{accessToSodiohome && ( {accessToSodistore && (
<List component="div"> <List component="div">
<ListItem component="div"> <ListItem component="div">
<Button <Button
disableRipple disableRipple
component={RouterLink} component={RouterLink}
onClick={closeSidebar} onClick={closeSidebar}
to="/sodiohome_installations" to="/sodistore_installations"
startIcon={<BrightnessLowTwoToneIcon />} startIcon={<BrightnessLowTwoToneIcon />}
> >
<Box sx={{ marginTop: '3px' }}> <Box sx={{ marginTop: '3px' }}>
<FormattedMessage <FormattedMessage
id="sodiohome" id="sodistore"
defaultMessage="Sodistore Home" defaultMessage="Sodistore Max"
/>
</Box>
</Button>
</ListItem>
</List>
)}
{accessToSalimax && (
<List component="div">
<ListItem component="div">
<Button
disableRipple
component={RouterLink}
onClick={closeSidebar}
to="/installations"
startIcon={<BrightnessLowTwoToneIcon />}
>
<Box sx={{ marginTop: '3px' }}>
<FormattedMessage id="salimax" defaultMessage="Salimax" />
</Box>
</Button>
</ListItem>
</List>
)}
{accessToSalidomo && (
<List component="div">
<ListItem component="div">
<Button
disableRipple
component={RouterLink}
onClick={closeSidebar}
to="/salidomo_installations"
startIcon={<BrightnessLowTwoToneIcon />}
>
<Box sx={{ marginTop: '3px' }}>
<FormattedMessage
id="salidomo"
defaultMessage="Salidomo"
/> />
</Box> </Box>
</Button> </Button>

View File

@ -1,17 +1,10 @@
import { ReactNode, useContext, useEffect, useState } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import { alpha, Box, lighten, useTheme } from '@mui/material'; import { alpha, Box, lighten, useTheme } from '@mui/material';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import Joyride, { CallBackProps, STATUS, Step } from 'react-joyride'; import Joyride, { CallBackProps, EVENTS, STATUS, Step } from 'react-joyride';
import { useIntl, IntlShape } from 'react-intl'; import { useIntl } from 'react-intl';
import { useTour } from 'src/contexts/TourContext'; import { useTour } from 'src/contexts/TourContext';
import { UserContext } from 'src/contexts/userContext'; import { buildDynamicTourSteps } from 'src/config/tourSteps';
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 Sidebar from './Sidebar';
import Header from './Header'; import Header from './Header';
@ -22,38 +15,11 @@ interface SidebarLayoutProps {
onSelectLanguage: (item: string) => void; 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 SidebarLayout = (props: SidebarLayoutProps) => {
const theme = useTheme(); const theme = useTheme();
const intl = useIntl(); const intl = useIntl();
const { runTour, stopTour } = useTour(); const { runTour, stopTour } = useTour();
const location = useLocation(); const location = useLocation();
const { currentUser } = useContext(UserContext);
const [tourSteps, setTourSteps] = useState<Step[]>([]); const [tourSteps, setTourSteps] = useState<Step[]>([]);
const [tourReady, setTourReady] = useState(false); const [tourReady, setTourReady] = useState(false);
@ -64,23 +30,22 @@ const SidebarLayout = (props: SidebarLayoutProps) => {
} }
// Delay to let child components render their tour target elements // Delay to let child components render their tour target elements
const timer = setTimeout(() => { const timer = setTimeout(() => {
const userType = currentUser?.userType ?? UserType.client;
const isInsideInstallation = location.pathname.includes('/installation/'); const isInsideInstallation = location.pathname.includes('/installation/');
const steps = getTourSteps(location.pathname, userType, intl, isInsideInstallation); const steps = buildDynamicTourSteps(intl, isInsideInstallation);
const filtered = steps.filter((step) => { setTourSteps(steps);
if (typeof step.target === 'string') {
return document.querySelector(step.target) !== null;
}
return true;
});
setTourSteps(filtered);
setTourReady(true); setTourReady(true);
}, 300); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [runTour, location.pathname, currentUser?.userType, intl]); }, [runTour, location.pathname, intl]);
const handleJoyrideCallback = (data: CallBackProps) => { const handleJoyrideCallback = (data: CallBackProps) => {
const { status } = data; const { status, step, type } = data;
if (type === EVENTS.STEP_BEFORE && step?.target) {
const el = document.querySelector(step.target as string);
if (el) {
el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
}
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) { if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
stopTour(); stopTour();
} }