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
steps:
- 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."
- uses: actions/setup-dotnet@v3
- uses: actions/setup-dotnet@55ec9447dda3d1cf6bd587150f3262f30ee10815 # v3
with:
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
- 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: |
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
- name: stop services
uses: appleboy/ssh-action@v0.1.4
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
with:
host: 194.182.190.208
username: ubuntu
@ -31,7 +32,7 @@ jobs:
sudo systemctl stop backend
- name: Copy Backend
uses: appleboy/scp-action@v0.1.4
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
with:
host: 194.182.190.208
username: ubuntu
@ -42,7 +43,7 @@ jobs:
strip_components: 1
- name: Copy Frontend
uses: appleboy/scp-action@v0.1.4
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
with:
host: 194.182.190.208
username: ubuntu
@ -53,12 +54,11 @@ jobs:
strip_components: 1
- name: restart services
uses: appleboy/ssh-action@v0.1.4
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
with:
host: 194.182.190.208
username: ubuntu
password: ${{ secrets.PRODUCTION_SSH_PASSPHRASE }}
script: |
sudo systemctl restart backend
sudo cp -rf ~/frontend/build/* /var/www/html/monitor.innov.energy/html/
sudo npm install -g serve
sudo cp -rf ~/frontend/build/* /var/www/html/monitor.innov.energy/html/

View File

@ -9,19 +9,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- 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."
- uses: actions/setup-dotnet@v3
- uses: actions/setup-dotnet@55ec9447dda3d1cf6bd587150f3262f30ee10815 # v3
with:
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
- 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: |
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
- name: stop services
uses: appleboy/ssh-action@v0.1.4
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
with:
host: 91.92.154.141
username: ubuntu
@ -30,7 +31,7 @@ jobs:
sudo systemctl stop backend
- name: Copy Backend
uses: appleboy/scp-action@v0.1.4
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
with:
host: 91.92.154.141
username: ubuntu
@ -41,7 +42,7 @@ jobs:
strip_components: 11
- name: Copy Frontend
uses: appleboy/scp-action@v0.1.4
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
with:
host: 91.92.154.141
username: ubuntu
@ -52,12 +53,11 @@ jobs:
strip_components: 5
- name: restart services
uses: appleboy/ssh-action@v0.1.4
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
with:
host: 91.92.154.141
username: ubuntu
password: ${{ secrets.STAGE_SSH_PASSPHRASE }}
script: |
sudo systemctl restart backend
sudo cp -rf ~/frontend/build/* /var/www/html/stage.innov.energy/html/
sudo npm install -g serve
sudo cp -rf ~/frontend/build/* /var/www/html/stage.innov.energy/html/

View File

@ -7,6 +7,8 @@ using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.App.Backend.Relations;
using InnovEnergy.App.Backend.Services;
using InnovEnergy.App.Backend.Websockets;
using InnovEnergy.Lib.S3Utils;
using InnovEnergy.Lib.S3Utils.DataTypes;
using InnovEnergy.Lib.Utils;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
@ -202,6 +204,8 @@ public class Controller : ControllerBase
bucketPath = "s3://" + installation.S3BucketId + "-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa/" + startTimestamp;
else if (installation.Product == (int)ProductType.SodistoreGrid)
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
bucketPath = "s3://" + installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/" + startTimestamp;
Console.WriteLine("Fetching data for "+startTimestamp);
@ -815,9 +819,10 @@ public class Controller : ControllerBase
if (installation is null || !user.HasAccessTo(installation))
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 &&
installation.Product != (int)ProductType.SodiStoreMax)
installation.Product != (int)ProductType.SodiStoreMax &&
installation.Product != (int)ProductType.SodistorePro)
return BadRequest("AI diagnostics not available for this product.");
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 ──────────────────────────────────────
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>
/// Returns a weekly performance report. Serves from cache if available;
/// generates fresh on first request or when forceRegenerate is true.
@ -951,43 +1033,9 @@ public class Controller : ControllerBase
{
var lang = language ?? user.Language ?? "en";
// Compute target week dates for cache lookup
DateOnly periodStart, periodEnd;
if (weekStartDate.HasValue)
{
periodStart = weekStartDate.Value;
periodEnd = weekStartDate.Value.AddDays(6);
}
else
{
(periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek();
}
var periodStartStr = periodStart.ToString("yyyy-MM-dd");
var periodEndStr = periodEnd.ToString("yyyy-MM-dd");
// Cache-first: check if a cached report exists for this week
if (!forceRegenerate)
{
var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr);
if (cached != null)
{
var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang);
if (cachedResponse != null)
{
Console.WriteLine($"[GetWeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}{periodEndStr}, language={lang}");
return Ok(cachedResponse);
}
}
}
// Cache miss or forceRegenerate: generate fresh
Console.WriteLine($"[GetWeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}{periodEndStr}");
var report = await WeeklyReportService.GenerateReportAsync(
installationId, installation.Name, lang, weekStartDate);
// Persist weekly summary and seed AiInsightCache for this language
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
var report = await FetchWeeklyReportAsync(installationId, installation.Name, lang, weekStartDate, forceRegenerate);
if (report == null)
return BadRequest("Failed to generate report.");
return Ok(report);
}
@ -1094,6 +1142,46 @@ public class Controller : ControllerBase
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>
/// Manually trigger monthly aggregation for an installation.
/// Computes monthly report from daily records for the specified year/month.
@ -1474,7 +1562,9 @@ public class Controller : ControllerBase
// ── Report HTML (for PDF download) ─────────────────────────────
[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;
if (user == null) return Unauthorized();
@ -1482,14 +1572,26 @@ public class Controller : ControllerBase
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation)) return Unauthorized();
var lang = language ?? user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang);
var html = ReportEmailService.BuildHtmlEmail(report, lang);
var lang = language ?? user.Language ?? "en";
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");
}
[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;
if (user == null) return Unauthorized();
@ -1508,12 +1610,12 @@ public class Controller : ControllerBase
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
$"{report.WeekCount} {s.CountLabel}", s);
$"{report.WeekCount} {s.CountLabel}", s, source: source);
return Content(html, "text/html");
}
[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;
if (user == null) return Unauthorized();
@ -1532,7 +1634,7 @@ public class Controller : ControllerBase
report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight,
$"{report.MonthCount} {s.CountLabel}", s);
$"{report.MonthCount} {s.CountLabel}", s, source: source);
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.");
}
[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;
if (user is null || user.UserType != 2) return Unauthorized();
@ -2146,6 +2273,14 @@ public class Controller : ControllerBase
var ticket = Db.GetTicketById(id);
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.");
}
@ -2309,4 +2444,226 @@ public class Controller : ControllerBase
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,
SodioHome =2,
SodiStoreMax=3,
SodistoreGrid=4
SodistoreGrid=4,
SodistorePro=5
}
public enum StatusType
@ -27,6 +28,13 @@ public class Installation : TreeNode
public String Location { get; set; } = "";
public String Region { 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 InstallationName { get; set; } = "";
@ -52,10 +60,12 @@ public class Installation : TreeNode
public string PvStringsPerInverter { get; set; } = "";
public string InstallationModel { get; set; } = "";
public string ExternalEms { get; set; } = "No";
public string CouplingType { get; set; } = "DC";
[Ignore]
public String OrderNumbers { get; set; }
public String VrmLink { get; set; } = "";
public string Configuration { 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:
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.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + 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:
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.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + installation.Name:
Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name;
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 SodioHomeBucketNameSalt = "e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa";
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)
{
@ -29,6 +30,11 @@ public static class InstallationMethods
return $"{installation.S3BucketId}-{SodistoreGridBucketNameSalt}";
}
if (installation.Product == (int)ProductType.SodistorePro)
{
return $"{installation.S3BucketId}-{SodistoreProBucketNameSalt}";
}
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
&& user.UserType != 0
@ -295,7 +295,7 @@ public static class SessionMethods
.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

View File

@ -243,22 +243,22 @@ public static class UserMethods
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
"Passwort Ihres Inesco Energy Kontos zurücksetzen",
"Passwort Ihres inesco energy Kontos zurücksetzen",
$"Sehr geehrte/r {user.Name}\n" +
$"Um Ihr Passwort zurückzusetzen, öffnen Sie bitte diesen Link: {resetLink}?token={encodedToken}"
),
"fr" => (
"Réinitialisation du mot de passe de votre compte Inesco Energy",
"Réinitialisation du mot de passe de votre compte inesco energy",
$"Cher/Chère {user.Name}\n" +
$"Pour réinitialiser votre mot de passe, veuillez ouvrir ce lien : {resetLink}?token={encodedToken}"
),
"it" => (
"Reimposta la password del tuo account Inesco Energy",
"Reimposta la password del tuo account inesco energy",
$"Gentile {user.Name}\n" +
$"Per reimpostare la password, apra questo link: {resetLink}?token={encodedToken}"
),
_ => (
"Reset the password of your Inesco Energy Account",
"Reset the password of your inesco energy Account",
$"Dear {user.Name}\n" +
$"To reset your password please open this link: {resetLink}?token={encodedToken}"
)
@ -274,28 +274,89 @@ public static class UserMethods
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
"Ihr neues Inesco Energy Konto",
"Ihr neues inesco energy Konto",
$"Sehr geehrte/r {user.Name}\n" +
$"Um Ihr Passwort festzulegen und sich bei Ihrem Inesco Energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}"
$"Um Ihr Passwort festzulegen und sich bei Ihrem inesco energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}"
),
"fr" => (
"Votre nouveau compte Inesco Energy",
"Votre nouveau compte inesco energy",
$"Cher/Chère {user.Name}\n" +
$"Pour définir votre mot de passe et vous connecter à votre compte Inesco Energy, veuillez ouvrir ce lien : {resetLink}"
$"Pour définir votre mot de passe et vous connecter à votre compte inesco energy, veuillez ouvrir ce lien : {resetLink}"
),
"it" => (
"Il tuo nuovo account Inesco Energy",
"Il tuo nuovo account inesco energy",
$"Gentile {user.Name}\n" +
$"Per impostare la password e accedere al suo account Inesco Energy, apra questo link: {resetLink}"
$"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" +
$"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"
)
};
return user.SendEmail(subject, body);
}
}

View File

@ -94,6 +94,11 @@ public class MonthlyReportSummary
public Int32 WeekCount { get; set; }
public String AiInsight { 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>
@ -137,19 +142,22 @@ public class YearlyReportSummary
public Int32 MonthCount { get; set; }
public String AiInsight { 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) ──
public class PendingMonth
{
public Int32 Year { get; set; }
public Int32 Month { get; set; }
public Int32 WeekCount { get; set; }
public Int32 Year { get; set; }
public Int32 Month { get; set; }
public Int32 WeekCount { get; set; }
}
public class PendingYear
{
public Int32 Year { get; set; }
public Int32 MonthCount { get; set; }
public Int32 Year { get; set; }
public Int32 MonthCount { get; set; }
}

View File

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

View File

@ -75,11 +75,21 @@ public static partial class Db
public static Boolean Create(HourlyEnergyRecord record) => Insert(record);
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
public static Boolean Create(Ticket ticket) => Insert(ticket);
public static Boolean Create(TicketComment comment) => Insert(comment);
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
// Document storage
public static Boolean Create(Document document) => Insert(document);
public static void HandleAction(UserAction newAction)
{

View File

@ -31,6 +31,7 @@ public static partial class Db
public static TableQuery<DailyEnergyRecord> DailyRecords => Connection.Table<DailyEnergyRecord>();
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
public static TableQuery<EmailPreference> EmailPreferences => Connection.Table<EmailPreference>();
// Ticket system tables
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<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
// Document storage
public static TableQuery<Document> Documents => Connection.Table<Document>();
public static void Init()
{
@ -69,12 +73,16 @@ public static partial class Db
Connection.CreateTable<DailyEnergyRecord>();
Connection.CreateTable<HourlyEnergyRecord>();
Connection.CreateTable<AiInsightCache>();
Connection.CreateTable<EmailPreference>();
// Ticket system tables
Connection.CreateTable<Ticket>();
Connection.CreateTable<TicketComment>();
Connection.CreateTable<TicketAiDiagnosis>();
Connection.CreateTable<TicketTimelineEvent>();
// Document storage
Connection.CreateTable<Document>();
});
// 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 = 'it' WHERE Language = 'italian'");
// One-time migration: rebrand to inesco Energy
Connection.Execute("UPDATE Folder SET Name = 'inesco Energy' WHERE Name = 'InnovEnergy'");
// One-time migration: rebrand to inesco energy
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'");
Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'");
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();
CleanupSessions().SupressAwaitWarning();
@ -123,6 +133,7 @@ public static partial class Db
fileConnection.CreateTable<DailyEnergyRecord>();
fileConnection.CreateTable<HourlyEnergyRecord>();
fileConnection.CreateTable<AiInsightCache>();
fileConnection.CreateTable<EmailPreference>();
// Ticket system tables
fileConnection.CreateTable<Ticket>();
@ -130,6 +141,9 @@ public static partial class Db
fileConnection.CreateTable<TicketAiDiagnosis>();
fileConnection.CreateTable<TicketTimelineEvent>();
// Document storage
fileConnection.CreateTable<Document>();
// 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 InstallationModel = '' WHERE InstallationModel IS NULL");

View File

@ -129,12 +129,22 @@ public static partial class Db
.Select(t => t.Id).ToList();
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);
TicketAiDiagnoses .Delete(d => d.TicketId == tid);
TicketTimelineEvents.Delete(e => e.TicketId == tid);
}
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;
}
}
@ -218,6 +228,17 @@ public static partial class Db
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);
TicketAiDiagnoses .Delete(d => d.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>
/// Deletes all report records older than 1 year. Called annually on Jan 2
/// after yearly reports are created. Uses fetch-then-delete for string-compared

View File

@ -161,6 +161,11 @@ public static partial class Db
&& c.Language == language)
?.InsightText;
// ── EmailPreference Queries ─────────────────────────────────────────
public static EmailPreference? GetEmailPreference(Int64 installationId)
=> EmailPreferences.FirstOrDefault(p => p.InstallationId == installationId);
// ── Ticket Queries ──────────────────────────────────────────────────
public static Ticket? GetTicketById(Int64 id)
@ -205,4 +210,27 @@ public static partial class Db
.Distinct()
.OrderBy(s => s)
.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",
"SmtpPassword" : "1ci4vi%+bfccIp",
"SmtpPort" : 587,
"SenderName" : "Inesco Energy",
"SenderName" : "inesco energy",
"SenderAddress" : "no-reply@inesco.ch"
}

View File

@ -8,6 +8,9 @@ using InnovEnergy.App.Backend.DeleteOldData;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models;
using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.Lib.S3Utils;
using InnovEnergy.Lib.S3Utils.DataTypes;
using InnovEnergy.Lib.Utils;
namespace InnovEnergy.App.Backend;
@ -26,6 +29,7 @@ public static class Program
Watchdog.NotifyReady();
Db.Init();
LoadEnvFile();
EnsureDocumentBucketExists().SupressAwaitWarning();
DiagnosticService.Initialize();
TicketDiagnosticService.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
{
Title = "Inesco Backend API",

View File

@ -17,6 +17,7 @@ public class Session : Relation<String, Int64>
public Boolean AccessToSodistoreMax { get; set; } = false;
public Boolean AccessToSodioHome { get; set; } = false;
public Boolean AccessToSodistoreGrid { get; set; } = false;
public Boolean AccessToSodistorePro { get; set; } = false;
[Ignore] public Boolean Valid => DateTime.Now - LastSeen <=MaxAge ;
// Private backing field
@ -51,6 +52,7 @@ public class Session : Relation<String, Int64>
AccessToSodistoreMax = user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count > 0;
AccessToSodioHome = user.AccessibleInstallations(product: (int)ProductType.SodioHome).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("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.Serialization;
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.
/// S3 key: DDMMYYYY.json (directly in bucket root).
/// Returns file content or null if not found / error.
/// Handles base64-encoded files (SinexcelCommunication uploads base64).
/// </summary>
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 s3Url = bucket.Path(fileName);
return await s3Url.GetObjectAsString();
var raw = await s3Url.GetObjectAsString();
return DecodeContent(raw);
}
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 ---
private sealed class HourlyJsonDto

View File

@ -1223,7 +1223,7 @@ textarea:focus{outline:none;border-color:#3498db}
<div id="app"></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 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>
var ALARMS = %%ALARMS_JSON%%;
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>
</td></tr>
<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>
</table></td></tr></table></body></html>
""";
@ -1545,7 +1545,7 @@ render();
<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: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>
""";
await SendEmailAsync(email, subject, html);
@ -1645,7 +1645,7 @@ render();
<table style="border-collapse:collapse;width:100%">
{beforeAfterRows}
</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>
""";

View File

@ -50,7 +50,7 @@ public static class DailyIngestionService
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
var installations = Db.Installations
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.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();
foreach (var installation in installations)

View File

@ -106,7 +106,7 @@ public static class ReportAggregationService
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
var installations = Db.Installations
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.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();
var generated = 0;
@ -121,6 +121,9 @@ public static class ReportAggregationService
generated++;
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)
{
@ -364,20 +367,23 @@ public static class ReportAggregationService
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,
installation?.Location, installation?.Country, installation?.Region);
weatherCity, installation?.Country, weatherRegion);
var monthlySummary = new MonthlyReportSummary
{
InstallationId = installationId,
Year = year,
Month = month,
PeriodStart = first.ToString("yyyy-MM-dd"),
PeriodEnd = last.ToString("yyyy-MM-dd"),
PeriodStart = days.Min(d => d.Date), // actual first data day, not calendar month start
PeriodEnd = days.Max(d => d.Date), // actual last data day, not calendar month end
TotalPvProduction = totalPv,
TotalConsumption = totalConsump,
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}).");
// Auto-send email if preference is set
await TryAutoSendMonthlyEmail(installationId, monthlySummary);
}
// ── 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).");
// 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 ──────────────────────────────────────────────
@ -591,6 +676,8 @@ public static class ReportAggregationService
var installationName = installation?.Name
?? $"Installation {report.InstallationId}";
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,
() => GenerateMonthlyAiInsightAsync(
installationName, monthName, report.WeekCount,
@ -599,7 +686,7 @@ public static class ReportAggregationService
report.TotalBatteryCharged, report.TotalBatteryDischarged,
report.TotalEnergySaved, report.TotalSavingsCHF,
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>
@ -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.";
}
// ── 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>
/// Formats a forecast list into a compact text block for AI prompt injection.
/// </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 "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>
@ -145,6 +187,44 @@ public static class WeatherService
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
{
0 => "Clear sky",

View File

@ -179,9 +179,9 @@ public static class WeeklyReportService
// 4. Get installation location for weather forecast
var installation = Db.GetInstallationById(installationId);
var location = installation?.Location;
var location = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
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}");
return await GenerateReportFromDataAsync(
@ -274,7 +274,8 @@ public static class WeeklyReportService
var aiInsight = await GetAiInsightAsync(
currentWeekDays, currentSummary, previousSummary,
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
var availableDates = currentWeekDays.Select(d => d.Date).ToHashSet();
@ -356,6 +357,8 @@ public static class WeeklyReportService
BehavioralPattern behavior,
string installationName,
string language = "en",
DateOnly? periodStart = null,
DateOnly? periodEnd = null,
string? location = null,
string? country = null,
string? region = null)
@ -367,7 +370,23 @@ public static class WeeklyReportService
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 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)")}");
@ -399,22 +418,29 @@ public static class WeeklyReportService
var battDepleteLine = hasBattery
? (behavior.AvgBatteryDepletedHour >= 0
? $"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
? $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day. Weekend avg: {behavior.WeekendAvgDailyLoad} 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
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
? $"- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged)."
: "";
var gridDailyFact = hasGrid
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
? $"- Grid import: {current.TotalGridImport:F1} kWh total for {periodLabel}."
: "";
// Behavioral section — only include when hourly data exists
@ -432,7 +458,7 @@ public static class WeeklyReportService
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
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
- {weekdayWeekendLine}{pvBehaviorLines}
{gridBehaviorLine}
@ -440,12 +466,15 @@ BEHAVIORAL PATTERN (from hourly data this week):
}
// 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
? $"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
? $"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.";
var instruction3 = hasBattery
@ -455,19 +484,24 @@ BEHAVIORAL PATTERN (from hourly data this week):
// Instruction 4 — adapts based on whether we have behavioral data
string instruction4;
if (hasBehavior && hasPv)
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has.";
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)
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
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
var hasWeather = forecast != null;
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 = "";
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 bestDayPv = Math.Round(bestDay.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 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)
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)
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)
{
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}"".
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.
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: 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}
DAILY FACTS:
- Total consumption: {current.TotalConsumption:F1} kWh this week. Self-sufficiency: {selfSufficiency}%.
DAILY FACTS (for {periodLabel}):
- Total consumption: {current.TotalConsumption:F1} kWh. Self-sufficiency: {selfSufficiency}%.
{pvDailyFact}
{battDailyFact}
{gridDailyFact}
{behavioralSection}
{historicalBlock}
{weatherBlock}
INSTRUCTIONS:
{instruction1}

View File

@ -105,6 +105,11 @@ public static class RabbitMqManager
monitorLink =
$"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
{
monitorLink =
@ -131,7 +136,7 @@ public static class RabbitMqManager
Console.WriteLine("Send replace battery email to the support team for installation "+installationId);
string recipient = "support@innov.energy";
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"+
$"Installation Name: {installation.Name}\n"+
$"\n"+
@ -143,7 +148,7 @@ public static class RabbitMqManager
$"\n"+
$"Thank you for your great support:)";
//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
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.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.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);

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 MaximumDischargingCurrent { get; set; }
public Double MaximumChargingCurrent { get; set; }
public WorkingMode OperatingPriority { get; set; }
public OperatingPriority OperatingPriority { get; set; }
public Int16 BatteriesCount { get; set; }
public Int16 ClusterNumber { 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 required SinexcelRecord InverterRecord { get; set; }
public required InverterRecords? InverterRecord { get; set; }
public required Config Config { get; set; }
}

View File

@ -13,20 +13,37 @@ public static class MiddlewareAgent
private static IPAddress? _controllerIpAddress;
private static EndPoint? _endPoint;
public static void InitializeCommunicationToMiddleware()
public static bool InitializeCommunicationToMiddleware()
{
_controllerIpAddress = FindVpnIp();
if (Equals(IPAddress.None, _controllerIpAddress))
try
{
Console.WriteLine("There is no VPN interface, exiting...");
_controllerIpAddress = FindVpnIp();
if (Equals(IPAddress.None, _controllerIpAddress))
{
Console.WriteLine("There is no VPN interface.");
_udpListener = null;
return false;
}
const int udpPort = 9000;
_endPoint = new IPEndPoint(_controllerIpAddress, udpPort);
_udpListener?.Close();
_udpListener?.Dispose();
_udpListener = new UdpClient();
_udpListener.Client.Blocking = false;
_udpListener.Client.Bind(_endPoint);
Console.WriteLine($"UDP listener bound to {_endPoint}");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Failed to initialize middleware communication: {ex}");
_udpListener = null;
return false;
}
const Int32 udpPort = 9000;
_endPoint = new IPEndPoint(_controllerIpAddress, udpPort);
_udpListener = new UdpClient();
_udpListener.Client.Blocking = false;
_udpListener.Client.Bind(_endPoint);
}
private static IPAddress FindVpnIp()
@ -50,42 +67,96 @@ public static class MiddlewareAgent
return IPAddress.None;
}
public static Configuration? SetConfigurationFile()
{
if (_udpListener.Available > 0)
try
{
// Ensure listener is initialized
if (_udpListener == null)
{
Console.WriteLine("UDP listener not initialized, trying to initialize...");
InitializeCommunicationToMiddleware();
if (_udpListener == null)
{
Console.WriteLine("Failed to initialize UDP listener.");
return null;
}
}
// Check if data is available
if (_udpListener.Available <= 0)
return null;
IPEndPoint? serverEndpoint = null;
var replyMessage = "ACK";
var replyData = Encoding.UTF8.GetBytes(replyMessage);
var udpMessage = _udpListener.Receive(ref serverEndpoint);
var message = Encoding.UTF8.GetString(udpMessage);
var message = Encoding.UTF8.GetString(udpMessage);
Console.WriteLine($"Received raw UDP message from {serverEndpoint}: {message}");
var config = JsonSerializer.Deserialize<Configuration>(message);
if (config != null)
{
Console.WriteLine($"Received a configuration message: " +
"MinimumSoC is " + config.MinimumSoC + " and operating priorty is " +config.OperatingPriority + "Number of batteries is " + config.BatteriesCount
+ "Maximum Charging current is "+ config.MaximumChargingCurrent + "/n" + "Maximum Discharging current is " + config.MaximumDischargingCurrent
+ "StartTimeChargeandDischargeDayandTime is" + config.StartTimeChargeandDischargeDayandTime + "StopTimeChargeandDischargeDayandTime is" + config.StopTimeChargeandDischargeDayandTime
+ "TimeChargeandDischargePowert is " + config.TimeChargeandDischargePower + " Control permission is" + config.ControlPermission);
// Send the reply to the sender's endpoint
Console.WriteLine(
$"Received a configuration message:\n" +
$"MinimumSoC: {config.MinimumSoC}\n" +
$"OperatingPriority: {config.OperatingPriority}\n" +
$"Number of batteries: {config.BatteriesCount}\n" +
$"Maximum Charging current: {config.MaximumChargingCurrent}\n" +
$"Maximum Discharging current: {config.MaximumDischargingCurrent}\n" +
$"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);
_udpListener.Send(replyData, replyData.Length, serverEndpoint);
Console.WriteLine($"Replied to {serverEndpoint}: {replyMessage}");
return config;
}
else
{
Console.WriteLine("Received UDP message but failed to deserialize Configuration.");
return null;
}
}
if (_endPoint != null && !_endPoint.Equals(_udpListener.Client.LocalEndPoint as IPEndPoint))
catch (SocketException ex)
{
Console.WriteLine("UDP address has changed, rebinding...");
Console.WriteLine($"Socket error in SetConfigurationFile: {ex}");
// Recover by reinitializing
try
{
_udpListener?.Close();
_udpListener?.Dispose();
}
catch
{
// ignored
}
_udpListener = null;
InitializeCommunicationToMiddleware();
return null;
}
catch (JsonException ex)
{
Console.WriteLine($"JSON deserialization error: {ex}");
return null;
}
catch (Exception ex)
{
Console.WriteLine($"Unexpected error in SetConfigurationFile: {ex}");
return null;
}
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.Text;
using System.Text.Json;
@ -24,6 +25,7 @@ using Formatting = Newtonsoft.Json.Formatting;
using JsonSerializer = System.Text.Json.JsonSerializer;
using static InnovEnergy.App.SinexcelCommunication.MiddlewareClasses.MiddlewareAgent;
using System.Diagnostics.CodeAnalysis;
using InnovEnergy.App.SinexcelCommunication.AggregationService;
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType;
using InnovEnergy.Lib.Protocols.Modbus.Protocol;
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")]
internal static class Program
{
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(5);
private const UInt16 NbrOfFileToConcatenate = 15; // add this to config file
private static UInt16 _fileCounter = 0;
private static Channel _sinexcelChannel1;
private static Channel _sinexcelChannel2;
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(10);
private const UInt16 NbrOfFileToConcatenate = 15; // add this to config file
private static UInt16 _fileCounter = 0;
private static List<Channel> _sinexcelChannel;
private static DateTime? _lastUploadedAggregatedDate;
private static DailyEnergyData? _pendingDailyData;
private static readonly String SwVersionNumber = " V1.00." + DateTime.Today;
private const String VpnServerIp = "10.2.0.11";
private static Boolean _subscribedToQueue = false;
@ -57,19 +59,29 @@ internal static class Program
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 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)
{
_sinexcelChannel1 = new SerialPortChannel(Port1, BaudRate, Parity, DataBits, StopBits);
_sinexcelChannel2 = new SerialPortChannel(Port2, BaudRate, Parity, DataBits, StopBits);
var config = Config.Load();
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();
while (true)
@ -91,21 +103,24 @@ internal static class Program
Watchdog.NotifyReady();
Console.WriteLine("Starting Sinexcel Communication, SW Version : " + SwVersionNumber);
var sinexcelDevice1 = new SinexcelDevice(_sinexcelChannel1, SlaveId);
var sinexcelDevice2 = new SinexcelDevice(_sinexcelChannel2, SlaveId);
var devices = _sinexcelChannel
.Where(ch => ch is not NullChannel)
.Select(ch => new SinexcelDevice(ch, SlaveId))
.ToList();
StatusRecord? ReadStatus()
{
var config = Config.Load();
var sinexcelRecord1 = sinexcelDevice1.Read();
var sinexcelRecord2 = sinexcelDevice2.Read();
var config = Config.Load();
var listOfInverterRecord = devices
.Select(device => device.Read())
.ToList();
InverterRecords? inverterRecords = InverterRecords.FromInverters(listOfInverterRecord);
return new StatusRecord
{
InverterRecord1 = sinexcelRecord1,
InverterRecord2 = sinexcelRecord2,
InverterRecord = inverterRecords,
Config = config // load from disk every iteration, so config can be changed while running
};
}
@ -128,103 +143,46 @@ internal static class Program
try
{
Watchdog.NotifyAlive();
var startTime = DateTime.Now;
Console.WriteLine("***************************** Reading Battery Data *********************************************");
Console.WriteLine(startTime.ToString("HH:mm:ss.fff ")+ "Start Reading");
// the order matter of the next three lines
var statusrecord = ReadStatus();
if (statusrecord == null)
return null;
_ = CreateAggregatedData(statusrecord);
Console.WriteLine(" ************************************************ Inverter 1 ************************************************ ");
Console.WriteLine( statusrecord.InverterRecord1.SystemDateTime + " SystemDateTime ");
Console.WriteLine( statusrecord.InverterRecord1.TotalPhotovoltaicPower + " TotalPhotovoltaicPower ");
Console.WriteLine( statusrecord.InverterRecord1.TotalBatteryPower + " TotalBatteryPower ");
Console.WriteLine( statusrecord.InverterRecord1.TotalLoadPower + " TotalLoadPower ");
Console.WriteLine( statusrecord.InverterRecord1.TotalGridPower + " TotalGridPower ");
var invDevices = statusrecord.InverterRecord?.Devices;
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 ");
if (invDevices != null)
{
var index = 1;
foreach (var inverter in invDevices)
PrintInverterData(inverter, index++);
}
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);
statusrecord.ControlConstants();
SendSalimaxStateAlarm(GetSodiHomeStateAlarm(statusrecord),statusrecord);
statusrecord.ControlConstants();
Console.WriteLine( " ************************************ We are writing ************************************");
var startWritingTime = DateTime.Now;
Console.WriteLine(startWritingTime.ToString("HH:mm:ss.fff ") +"start Writing");
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");
sinexcelDevice1.Write(statusrecord.InverterRecord1);
sinexcelDevice2.Write(statusrecord.InverterRecord2);
Console.WriteLine("We have the Right to Write");
foreach (var pair in devices.Zip(statusrecord.InverterRecord.Devices))
pair.First.Write(pair.Second);
}
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;
}
catch (CrcException e)
@ -239,60 +197,216 @@ 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)
{
if (statusrecord == null) return;
if (statusrecord?.InverterRecord?.Devices == null) return;
// Compute once (same for all inverters)
var config = statusrecord.Config;
var isChargePeriod = IsNowInsideDateAndTime(
config.StartTimeChargeandDischargeDayandTime,
config.StopTimeChargeandDischargeDayandTime
);
statusrecord.InverterRecord1.Battery1BackupSoc = (Single)statusrecord.Config.MinSoc ;
statusrecord.InverterRecord1.Battery2BackupSoc = (Single)statusrecord.Config.MinSoc ;
statusrecord.InverterRecord1.RepetitiveWeeks = SinexcelWeekDays.All;
var isChargePeriod = IsNowInsideDateAndTime(statusrecord.Config.StartTimeChargeandDischargeDayandTime, statusrecord.Config.StopTimeChargeandDischargeDayandTime);
Console.WriteLine("Are we inside the charge/Discharge time " + isChargePeriod);
if (statusrecord.Config.OperatingPriority != TimeChargeDischarge)
foreach (var inverter in statusrecord.InverterRecord.Devices)
{
statusrecord.InverterRecord1.WorkingMode = statusrecord.Config.OperatingPriority;
}
else if (statusrecord.Config.OperatingPriority == TimeChargeDischarge && isChargePeriod)
{
statusrecord.InverterRecord1.WorkingMode = statusrecord.Config.OperatingPriority;
// constants for every inverter
inverter.Battery1BackupSoc = (float)config.MinSoc;
inverter.Battery2BackupSoc = (float)config.MinSoc;
inverter.RepetitiveWeeks = SinexcelWeekDays.All;
if (statusrecord.Config.TimeChargeandDischargePower > 0)
var operatingMode = config.OperatingPriority switch
{
statusrecord.InverterRecord1.EffectiveStartDate = statusrecord.Config.StartTimeChargeandDischargeDayandTime.Date;
statusrecord.InverterRecord1.EffectiveEndDate = statusrecord.Config.StopTimeChargeandDischargeDayandTime.Date;
statusrecord.InverterRecord1.ChargingPowerPeriod1 = Math.Abs(statusrecord.Config.TimeChargeandDischargePower);
statusrecord.InverterRecord1.ChargeStartTimePeriod1 = statusrecord.Config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.ChargeEndTimePeriod1 = statusrecord.Config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.DischargeStartTimePeriod1 = TimeSpan.Zero;
statusrecord.InverterRecord1.DischargeEndTimePeriod1 = TimeSpan.Zero;
OperatingPriority.LoadPriority => SpontaneousSelfUse,
OperatingPriority.BatteryPriority => TimeChargeDischarge,
OperatingPriority.GridPriority => PrioritySellElectricity,
_ => SpontaneousSelfUse
};
if (operatingMode!= TimeChargeDischarge)
{
inverter.WorkingMode = operatingMode;
}
else if (isChargePeriod)
{
inverter.WorkingMode = operatingMode;
inverter.EffectiveStartDate = config.StartTimeChargeandDischargeDayandTime.Date;
inverter.EffectiveEndDate = config.StopTimeChargeandDischargeDayandTime.Date;
var power = config.TimeChargeandDischargePower;
if (power > 0)
{
inverter.ChargingPowerPeriod1 = Math.Abs(power);
inverter.ChargeStartTimePeriod1 = config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
inverter.ChargeEndTimePeriod1 = config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
inverter.DischargeStartTimePeriod1 = TimeSpan.Zero;
inverter.DischargeEndTimePeriod1 = TimeSpan.Zero;
}
else
{
inverter.DishargingPowerPeriod1 = Math.Abs(power);
inverter.DischargeStartTimePeriod1 = config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
inverter.DischargeEndTimePeriod1 = config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
inverter.ChargeStartTimePeriod1 = TimeSpan.Zero;
inverter.ChargeEndTimePeriod1 = TimeSpan.Zero;
}
}
else
{
statusrecord.InverterRecord1.EffectiveStartDate = statusrecord.Config.StartTimeChargeandDischargeDayandTime.Date;
statusrecord.InverterRecord1.EffectiveEndDate = statusrecord.Config.StopTimeChargeandDischargeDayandTime.Date;
statusrecord.InverterRecord1.DishargingPowerPeriod1 = Math.Abs(statusrecord.Config.TimeChargeandDischargePower);
statusrecord.InverterRecord1.DischargeStartTimePeriod1 = statusrecord.Config.StartTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.DischargeEndTimePeriod1 = statusrecord.Config.StopTimeChargeandDischargeDayandTime.TimeOfDay;
statusrecord.InverterRecord1.ChargeStartTimePeriod1 = TimeSpan.Zero;
statusrecord.InverterRecord1.ChargeEndTimePeriod1 = TimeSpan.Zero;
inverter.WorkingMode = SpontaneousSelfUse;
}
inverter.PowerOn = 1;
inverter.PowerOff = 0;
}
else
{
statusrecord.InverterRecord1.WorkingMode = SpontaneousSelfUse;
}
statusrecord.InverterRecord1.PowerOn = 1;
statusrecord.InverterRecord1.PowerOff = 0;
//statusrecord.InverterRecord.FaultClearing = 1;
}
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)
@ -309,7 +423,7 @@ internal static class Program
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 warningList = new List<AlarmOrWarning>();
@ -424,13 +538,7 @@ internal static class Program
var stateChanged = currentSalimaxState.Status != _prevSodiohomeAlarmState;
var contentChanged = HasErrorsOrWarningsChanged(currentSalimaxState);
var needsHeartbeat = (DateTime.Now - _lastHeartbeatTime).TotalSeconds >= HeartbeatIntervalSeconds;
Console.WriteLine($"subscribedNow={subscribedNow}");
Console.WriteLine($"_subscribedToQueue={_subscribedToQueue}");
Console.WriteLine($"stateChanged={stateChanged}");
Console.WriteLine($"contentChanged={contentChanged}");
Console.WriteLine($"needsHeartbeat={needsHeartbeat}");
Console.WriteLine($"s3Bucket null? {s3Bucket == null}");
if (s3Bucket == null)
{
Console.WriteLine("⚠ S3 bucket not configured. Skipping middleware send.");
@ -526,47 +634,48 @@ internal static class Program
}
private static async Task<Boolean> SaveModbusTcpFile(StatusRecord status)
private static async Task<Boolean> SaveModbusTcpFile(StatusRecord status)
{
var modbusData = new Dictionary<String, UInt16>();
// SYSTEM DATA
var result1 = ConvertToModbusRegisters((status.Config.ModbusProtcolNumber * 10), "UInt16", 30001); // this to be updated to modbusTCP version
var result2 = ConvertToModbusRegisters(status.InverterRecord1.SystemDateTime.ToUnixTime(), "UInt32", 30002);
// SYSTEM DATA
var result3 = ConvertToModbusRegisters(status.InverterRecord1.WorkingMode, "UInt16", 30004);
// BATTERY SUMMARY (assuming single battery [0]) // this to be improved
try
{
// SYSTEM DATA
var result1 = ConvertToModbusRegisters((status.Config.ModbusProtcolNumber * 10), "UInt16", 30001); // this to be updated to modbusTCP version
var result2 = ConvertToModbusRegisters(DateTimeOffset.Now.ToUnixTimeSeconds(), "UInt32", 30002);
// SYSTEM DATA
var result3 = ConvertToModbusRegisters(status.InverterRecord.OperatingPriority, "UInt16", 30004);
var result4 = ConvertToModbusRegisters((status.Config.BatteriesCount), "UInt16", 31000);
var result8 = ConvertToModbusRegisters((status.InverterRecord1.Battery1Voltage.Value * 10), "UInt16", 31001);
var result12 = ConvertToModbusRegisters((status.InverterRecord1.Battery2Voltage.Value * 10), "Int16", 31002);
var result13 = ConvertToModbusRegisters((status.InverterRecord1.Battery1Current.Value * 10), "Int32", 31003);
var result16 = ConvertToModbusRegisters((status.InverterRecord1.Battery2Current.Value * 10), "Int32", 31005);
var result9 = ConvertToModbusRegisters((status.InverterRecord1.Battery1Soc.Value * 100), "UInt16", 31007);
var result14 = ConvertToModbusRegisters((status.InverterRecord1.Battery2Soc.Value * 100), "UInt16", 31008);
var result5 = ConvertToModbusRegisters((status.InverterRecord1.TotalBatteryPower.Value * 10), "Int32", 31009);
var result8 = ConvertToModbusRegisters((0), "UInt16", 31001); // this is ignored as dosen't exist in Sinexcel
var result12 = ConvertToModbusRegisters((status.InverterRecord.AvgBatteryVoltage.Value * 10), "Int16", 31002);
var result13 = ConvertToModbusRegisters((status.InverterRecord.TotalBatteryCurrent.Value * 10), "Int32", 31003);
var result16 = ConvertToModbusRegisters((status.InverterRecord.AvgBatterySoc.Value * 100), "UInt16", 31005);
var result9 = ConvertToModbusRegisters((status.InverterRecord.TotalBatteryPower.Value * 10), "Int32", 31006);
var result14 = ConvertToModbusRegisters((status.InverterRecord.MinSoc.Value * 100), "UInt16", 31008);
var result55 = ConvertToModbusRegisters(100 * 100, "UInt16", 31009); //this is ignored as dosen't exist in Sinexcel
var result5 = ConvertToModbusRegisters((status.InverterRecord.AvgBatterySoh.Value * 100), "UInt16", 31009);
var 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 result7 = ConvertToModbusRegisters((status.InverterRecord.AvgBatteryTemp.Value * 100), "Int16", 31011);
var result20 = ConvertToModbusRegisters((status.InverterRecord.MaxChargeCurrent.Value * 10), "UInt16", 31012);
var result15 = ConvertToModbusRegisters((status.InverterRecord.MaxDischargingCurrent.Value * 10), "UInt16", 31013);
var result26 = ConvertToModbusRegisters(60 * 10, "UInt16", 31014); //this is ignored as dosen't exist in Sinexcel
var result18 = ConvertToModbusRegisters((status.InverterRecord1.PvTotalPower * 10), "UInt32", 32000);
var result19 = ConvertToModbusRegisters((status.InverterRecord1.GridPower * 10), "Int32", 33000);
var result23 = ConvertToModbusRegisters((status.InverterRecord1.GridVoltageFrequency * 10), "UInt16", 33002);
var result18 = ConvertToModbusRegisters((status.InverterRecord.TotalPhotovoltaicPower.Value * 10), "UInt32", 32000);
var result19 = ConvertToModbusRegisters((status.InverterRecord.TotalGridPower.Value * 10), "Int32", 33000);
var result23 = ConvertToModbusRegisters((status.InverterRecord.GridFrequency.Value * 10), "UInt16", 33002);
var result24 = ConvertToModbusRegisters((status.InverterRecord1.WorkingMode), "UInt16", 34000);
var result25 = ConvertToModbusRegisters((status.InverterRecord1.InverterActivePower * 10), "Int32", 34001);
var result29 = ConvertToModbusRegisters((status.InverterRecord1.EnableGridExport ), "UInt16", 34003);
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
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)
@ -580,11 +689,16 @@ internal static class Program
var json = JsonSerializer.Serialize(modbusData, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync("/home/inesco/SodiStoreHome/ModbusTCP/modbus_tcp_data.json", json);
//Console.WriteLine("JSON file written successfully.");
//Console.WriteLine(json);
var stopTime = DateTime.Now;
Console.WriteLine(stopTime.ToString("HH:mm:ss.fff" )+ " Finish the loop");
// Console.WriteLine("JSON file written successfully.");
// Console.WriteLine(json);
return true;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
private static Dictionary<string, ushort> ConvertToModbusRegisters(object value, string outputType, int startingAddress)

View File

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using InnovEnergy.App.SinexcelCommunication.DataTypes;
using InnovEnergy.Lib.Devices.Sinexcel_12K_TL.DataType;
using InnovEnergy.Lib.Utils;
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")]
public class Config
{
private static String DefaultConfigFilePath => Path.Combine(Environment.CurrentDirectory, "config.json");
private static DateTime DefaultDatetime => new(2025, 01, 01, 09, 00, 00);
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 GridSetPoint { get; set; }
public required Double MaximumDischargingCurrent { 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 ClusterNumber { get; set; }
public required Int16 PvNumber { get; set; }
@ -34,15 +39,26 @@ public class Config
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()
{
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,
GridSetPoint = 0,
MaximumChargingCurrent = 180,
MaximumDischargingCurrent = 180,
OperatingPriority = WorkingMode.TimeChargeDischarge,
OperatingPriority = OperatingPriority.LoadPriority,
BatteriesCount = 0,
ClusterNumber = 0,
PvNumber = 0,
@ -67,6 +83,10 @@ public class Config
{
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
{
var jsonString = Serialize(this, JsonOptions);
@ -84,13 +104,15 @@ public class Config
throw;
}
}
/*
public static Config Load(String? path = null)
{
var configFilePath = path ?? DefaultConfigFilePath;
try
{
var jsonString = File.ReadAllText(configFilePath);
// LoadedWriteTimeUtc = File.GetLastWriteTimeUtc(configFilePath);
return Deserialize<Config>(jsonString)!;
}
catch (Exception e)
@ -98,6 +120,31 @@ public class Config
$"Failed to read config file {configFilePath}, using default config\n{e}".WriteLine();
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)

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'
release_flag_file="./bin/Release/$dotnet_version/linux-arm64/publish/.release.flag"
DOTNET="/snap/dotnet-sdk_60/current/dotnet"
set -e
echo -e "\n============================ Build ============================\n"
dotnet publish \
"$DOTNET" publish \
./SinexcelCommunication.csproj \
-p:PublishTrimmed=false \
-c Release \

View File

@ -49,4 +49,10 @@ public enum SinexcelMachineMode
{
Single = 0, // Default
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.Units;
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
// ───────────────────────────────────────────────
@ -295,11 +296,12 @@ public partial class SinexcelRecord
public UInt16 Minute => (UInt16) ConvertBitPatternToFloat(_minute);
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
// ───────────────────────────────────────────────
/*
public Voltage DieselGenAPhaseVoltage => ConvertBitPatternToFloat(_dieselGenAPhaseVoltage);
public Voltage DieselGenBPhaseVoltage => ConvertBitPatternToFloat(_dieselGenBPhaseVoltage);
public Voltage DieselGenCPhaseVoltage => ConvertBitPatternToFloat(_dieselGenCPhaseVoltage);
@ -324,7 +326,7 @@ public partial class SinexcelRecord
public ReactivePower DieselGenAPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenAPhaseReactivePower) * _factorFromKwtoW;
public ReactivePower DieselGenBPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenBPhaseReactivePower) * _factorFromKwtoW;
public ReactivePower DieselGenCPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenCPhaseReactivePower) * _factorFromKwtoW;
public ReactivePower DieselGenCPhaseReactivePower => ConvertBitPatternToFloat(_dieselGenCPhaseReactivePower) * _factorFromKwtoW;*/
// ───────────────────────────────────────────────
// Photovoltaic and Battery Measurements
@ -645,14 +647,14 @@ public partial class SinexcelRecord
set => _battery2BackupSOC = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
}
// to be tested
public float EnableGridExport
public EnablePowerLimitation EnableGridExport
{
get => BitConverter.Int32BitsToSingle(unchecked((int)_enableGridExport));
set => _enableGridExport = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
get => (EnablePowerLimitation)ConvertBitPatternToFloat(_enableGridExport); //(Boolean)BitConverter.Int32BitsToSingle(unchecked((int)_enableGridExport));
set => _enableGridExport = (UInt32)value;
}
public float PowerGridExportLimit
public ActivePower PowerGridExportLimit
{
get => BitConverter.Int32BitsToSingle(unchecked((int)_powerGridExportLimit));
set => _powerGridExportLimit = BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
@ -813,15 +815,17 @@ public partial class SinexcelRecord
public Percent Battery1SocSecondvalue => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab1Soc)); // 0xB106 %
public Percent Battery1Soh => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab1Soh)); // 0xB108 %
// Energy (kW·h)
public float Battery2TotalChargingEnergy => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2TotalChargingEnergy)); // 0xB1FC
public float Battery2TotalDischargedEnergy => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2TotalDischargedEnergy)); // 0xB1FE
// Pack Voltage / Current / Temperature
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 Percent Battery2Socsecondvalue => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Soc)); // 0xB206 %
public Percent Battery2Soh => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Soh)); // 0xB208 %
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 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 Battery2Soh => BitConverter.Int32BitsToSingle(unchecked((Int32)_batteryCab2Soh)); // 0xB208 %
// Repetitive-week mask (bit-mapped 06 = SunSat)
@ -930,5 +934,9 @@ public partial class SinexcelRecord
byte[] bytes = BitConverter.GetBytes(rawValue);
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
// ───────────────────────────────────────────────
[HoldingRegister<UInt32>(4338)] private UInt32 _year; // 0x10F2
[HoldingRegister<UInt32>(4340)] private UInt32 _month; // 0x10F4
[HoldingRegister<UInt32>(4342)] private UInt32 _day; // 0x10F6
[HoldingRegister<UInt32>(4344)] private UInt32 _hour; // 0x10F8
[HoldingRegister<UInt32>(4346)] private UInt32 _minute; // 0x10FA
[HoldingRegister<UInt32>(4348)] private UInt32 _second; // 0x10FC
[HoldingRegister<UInt32>(4338/*, writable: true*/)] private UInt32 _year; // 0x10F2
[HoldingRegister<UInt32>(4340/*, writable: true*/)] private UInt32 _month; // 0x10F4
[HoldingRegister<UInt32>(4342/*, writable: true*/)] private UInt32 _day; // 0x10F6
[HoldingRegister<UInt32>(4344/*, writable: true*/)] private UInt32 _hour; // 0x10F8
[HoldingRegister<UInt32>(4346/*, writable: true*/)] private UInt32 _minute; // 0x10FA
[HoldingRegister<UInt32>(4348/*, writable: true*/)] private UInt32 _second; // 0x10FC
// ───────────────────────────────────────────────
// Diesel Generator Measurements
// ───────────────────────────────────────────────
/*
[HoldingRegister<UInt32>(4362)] private UInt32 _dieselGenAPhaseVoltage; // 0x110A
[HoldingRegister<UInt32>(4364)] private UInt32 _dieselGenBPhaseVoltage; // 0x110C
[HoldingRegister<UInt32>(4366)] private UInt32 _dieselGenCPhaseVoltage; // 0x110E
@ -318,8 +319,8 @@ public partial class SinexcelRecord
[HoldingRegister<UInt32>(4410)] private UInt32 _dieselGenBPhaseActivePower; // 0x113A
[HoldingRegister<UInt32>(4412)] private UInt32 _dieselGenCPhaseActivePower; // 0x113C
[HoldingRegister<UInt32>(4414)] private UInt32 _dieselGenAPhaseReactivePower; // 0x113E
[HoldingRegister<UInt32>(4416)] private UInt32 _dieselGenBPhaseReactivePower; // 0x1140
[HoldingRegister<UInt32>(4418)] private UInt32 _dieselGenCPhaseReactivePower; // 0x1142
[HoldingRegister<UInt32>(4416)] private UInt32 _dieselGenBPhaseReactivePower; // 0x1140*
[HoldingRegister<UInt32>(4418)] private UInt32 _dieselGenCPhaseReactivePower; // 0x1142*/
// ───────────────────────────────────────────────
// Photovoltaic and Battery Measurements

View File

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

View File

@ -34,15 +34,41 @@ function App() {
const searchParams = new URLSearchParams(location.search);
const username = searchParams.get('username');
const {
accessToSalimax,
accessToSodiohome,
accessToSodistore,
accessToSodistoreGrid,
accessToSodistorePro,
setAccessToSalimax,
setAccessToSalidomo,
setAccessToSodiohome,
setAccessToSodistore,
setAccessToSodistoreGrid
setAccessToSodistoreGrid,
setAccessToSodistorePro
} = 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>(
() => localStorage.getItem('language') || currentUser?.language || 'en'
() => localStorage.getItem('language') || currentUser?.language || detectBrowserLanguage()
);
const onSelectLanguage = (lang: string) => {
@ -94,11 +120,19 @@ function App() {
const Login = Loader(lazy(() => import('src/components/login')));
const Users = Loader(lazy(() => import('src/content/dashboards/Users')));
const loginToResetPassword = () => {
useEffect(() => {
if (!username || token) return;
axiosConfigWithoutToken
.post('/Login', null, { params: { username, password: '' } })
.then((response) => {
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);
setUser(response.data.user);
setAccessToSalimax(response.data.accessToSalimax);
@ -106,25 +140,11 @@ function App() {
setAccessToSodiohome(response.data.accessToSodioHome);
setAccessToSodistore(response.data.accessToSodistoreMax);
setAccessToSodistoreGrid(response.data.accessToSodistoreGrid);
if (response.data.accessToSalimax) {
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);
}
setAccessToSodistorePro(response.data.accessToSodistorePro);
}
})
.catch(() => {});
};
if (username) {
loginToResetPassword();
}
}, [username]);
if (!token) {
return (
@ -158,8 +178,14 @@ function App() {
if (token && currentUser?.mustResetPassword) {
return (
<ThemeProvider>
<CssBaseline />
<SetNewPassword></SetNewPassword>
<IntlProvider
messages={getTranslations()}
locale={language}
defaultLocale="en"
>
<CssBaseline />
<SetNewPassword></SetNewPassword>
</IntlProvider>
</ThemeProvider>
);
}
@ -177,11 +203,11 @@ function App() {
<Routes>
<Route
path={''}
element={<Navigate to={routes.installations}></Navigate>}
element={<Navigate to={defaultRoute}></Navigate>}
></Route>
<Route
path={'login'}
element={<Navigate to={routes.installations}></Navigate>}
element={<Navigate to={defaultRoute}></Navigate>}
></Route>
<Route
path="/"
@ -228,6 +254,15 @@ function App() {
}
/>
<Route
path={routes.sodistorepro_installations + '*'}
element={
<AccessContextProvider>
<SodioHomeInstallationTabs product={5} />
</AccessContextProvider>
}
/>
<Route
path={routes.sodistoregrid_installations + '*'}
element={
@ -241,7 +276,7 @@ function App() {
<Route path={routes.users + '*'} element={<Users />} />
<Route
path={'*'}
element={<Navigate to={routes.installations}></Navigate>}
element={<Navigate to={defaultRoute}></Navigate>}
></Route>
<Route path="ResetPassword" element={<ResetPassword />}></Route>
</Route>

View File

@ -20,8 +20,9 @@ export function formatPowerForGraph(value, magnitude): { value: number } {
}
}
const result = negative === false ? value : -value;
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/",
"sodiohome_installations": "/sodiohome_installations/",
"sodistoregrid_installations": "/sodistoregrid_installations/",
"sodistorepro_installations": "/sodistorepro_installations/",
"installation": "installation/",
"login": "/login/",
"forgotPassword": "/forgotPassword/",
@ -24,5 +25,6 @@
"detailed_view": "detailed_view/",
"report": "report",
"installationTickets": "installationTickets",
"documents": "documents",
"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>
<Typography variant="subtitle1">
&copy; 2025 - Inesco Energy AG
&copy; 2025 - inesco energy AG
</Typography>
</Box>
<Typography
@ -33,7 +33,7 @@ function Footer() {
target="_blank"
rel="noopener noreferrer"
>
Inesco Energy AG
inesco energy AG
</Link>
</Typography>
</Box>

View File

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

View File

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

View File

@ -1,117 +1,85 @@
import { Step } from 'react-joyride';
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(
intl: IntlShape,
target: string,
titleId: string,
contentId: string,
placement: Step['placement'] = 'bottom',
disableBeacon = false
): Step {
return {
target,
title: intl.formatMessage({ id: titleId }),
content: intl.formatMessage({ id: contentId }),
placement,
...(disableBeacon ? { disableBeacon: true } : {})
};
}
// --- Tab key → i18n key mapping ---
const tabConfig: Record<string, { titleId: string; contentId: string }> = {
list: { titleId: 'tourListTitle', contentId: 'tourListContent' },
tree: { titleId: 'tourTreeTitle', contentId: 'tourTreeContent' },
live: { titleId: 'tourLiveTitle', contentId: 'tourLiveContent' },
overview: { titleId: 'tourOverviewTitle', contentId: 'tourOverviewContent' },
batteryview: { titleId: 'tourBatteryviewTitle', contentId: 'tourBatteryviewContent' },
pvview: { titleId: 'tourPvviewTitle', contentId: 'tourPvviewContent' },
log: { titleId: 'tourLogTitle', contentId: 'tourLogContent' },
information: { titleId: 'tourInformationTitle', contentId: 'tourInformationContent' },
report: { titleId: 'tourReportTitle', contentId: 'tourReportContent' },
manage: { titleId: 'tourManageTitle', contentId: 'tourManageContent' },
configuration: { titleId: 'tourConfigurationTitle', contentId: 'tourConfigurationContent' },
history: { titleId: 'tourHistoryTitle', contentId: 'tourHistoryContent' }
const tabContentKey: Record<string, string> = {
list: 'tourListContent',
tree: 'tourTreeContent',
live: 'tourLiveContent',
overview: 'tourOverviewContent',
batteryview: 'tourBatteryviewContent',
pvview: 'tourPvviewContent',
log: 'tourLogContent',
information: 'tourInformationContent',
report: 'tourReportContent',
manage: 'tourManageContent',
configuration: 'tourConfigurationContent',
history: 'tourHistoryContent',
installationTickets: 'tourInstallationTicketsContent'
};
// Steps to skip inside a specific installation (already covered in the list-page tour)
const listPageOnlyTabs = new Set(['list', 'tree']);
// --- Build tour steps from tab value list ---
function buildTourSteps(intl: IntlShape, tabValues: string[], includeInstallationHint = false, isInsideInstallation = false): Step[] {
/**
* Build tour steps dynamically from the DOM.
* Scans for all rendered `#tour-tab-*` elements and creates a step for each.
* The step title is the tab's own rendered text (matching across languages).
*/
export function buildDynamicTourSteps(intl: IntlShape, isInsideInstallation: boolean): Step[] {
const steps: Step[] = [];
// Language selector step (only on list/tree pages, not inside an installation)
if (!isInsideInstallation) {
steps.push(makeStep(intl, '[data-tour="language-selector"]', 'tourLanguageTitle', 'tourLanguageContent', 'bottom', true));
}
for (const value of tabValues) {
if (isInsideInstallation && listPageOnlyTabs.has(value)) continue;
const cfg = tabConfig[value];
if (cfg) {
steps.push(makeStep(intl, `#tour-tab-${value}`, cfg.titleId, cfg.contentId, 'bottom', steps.length === 0));
const langEl = document.querySelector('[data-tour="language-selector"]');
if (langEl) {
steps.push({
target: '[data-tour="language-selector"]',
title: intl.formatMessage({ id: 'tourLanguageTitle' }),
content: intl.formatMessage({ id: 'tourLanguageContent' }),
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;
}
// --- 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,
Power: device?.[`Battery${batteryIndex}Power`] ?? 0,
Soc: device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0,
Soh: device?.[`Battery${batteryIndex}Soh`] ?? 0,
}
};
} else {
@ -69,7 +68,6 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
Current: inverter[`Battery${index}Current`] ?? 0,
Power: inverter[`Battery${index}Power`] ?? 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="current" defaultMessage="Current" /></TableCell>
<TableCell align="center"><FormattedMessage id="soc" defaultMessage="SoC" /></TableCell>
<TableCell align="center"><FormattedMessage id="soh" defaultMessage="SoH" /></TableCell>
{/*<TableCell align="center"><FormattedMessage id="soh" defaultMessage="SoH" /></TableCell>*/}
{/*<TableCell align="center">Daily Charge Energy</TableCell>*/}
{/*<TableCell align="center">Daily Discharge Energy</TableCell>*/}
</TableRow>
@ -240,12 +238,13 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
align="center"
sx={{ fontWeight: 'bold' }}
>
<Link
{/* Detailed battery view commented out */}
{/*<Link
style={{ color: 'black' }}
to={routes.detailed_view + BatteryId}
>
>*/}
{'Battery Cluster ' + BatteryId}
</Link>
{/*</Link>*/}
</TableCell>
<TableCell
sx={{
@ -293,7 +292,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
>
{battery.Soc + ' %'}
</TableCell>
<TableCell
{/*<TableCell
sx={{
width: '8%',
textAlign: 'center',
@ -307,7 +306,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
}}
>
{battery.Soh + ' %'}
</TableCell>
</TableCell>*/}
{/*<TableCell*/}
{/* sx={{*/}
{/* width: '15%',*/}

View File

@ -151,22 +151,25 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
pathsToSearch.push('Node' + i);
}
const total = pathsToSearch.length;
let i = 0;
pathsToSearch.forEach((devicePath) => {
if (
Object.hasOwnProperty.call(chartData[category].data, devicePath) &&
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({
...chartData[category].data[devicePath],
color:
color === 'blue'
? blueColors[i]
? blueColors[colorIndex]
: color === 'red'
? redColors[i]
? redColors[colorIndex]
: color === 'green'
? greenColors[i]
: orangeColors[i]
? greenColors[colorIndex]
: orangeColors[colorIndex]
});
}
i++;
@ -751,63 +754,7 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
</Card>
</Grid>
{/* Battery SoH Chart */}
<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>
{/* Battery SoH Chart — removed for SodiostoreHome */}
</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 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 [loadingProviders, setLoadingProviders] = useState(false);
@ -426,12 +426,18 @@ function Information(props: InformationProps) {
label="S3 Bucket Name"
name="s3bucketname"
value={
formValues.product === 0 || formValues.product == 3
formValues.product === 0 || formValues.product === 3
? formValues.s3BucketId +
'-3e5b3069-214a-43ee-8d85-57d72000c19d'
: formValues.product == 4
: formValues.product === 4
? formValues.s3BucketId +
'-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 +
'-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa'
}

View File

@ -34,7 +34,7 @@ import { useNavigate } from 'react-router-dom';
import { UserType } from '../../../interfaces/UserTypes';
import axiosConfig from '../../../Resources/axiosConfig';
import {
INSTALLATION_PRESETS,
getPresetsForDevice,
PresetConfig,
BatterySnTree,
parseBatterySnTree,
@ -42,6 +42,8 @@ import {
remapTree,
computeFlatValues,
wouldLoseData,
SODIOHOME_DEVICE_TYPES,
buildSodistoreProPreset,
} from './installationSetupUtils';
interface InformationSodistorehomeProps {
@ -60,7 +62,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
const theme = useTheme();
const intl = useIntl();
const [formValues, setFormValues] = useState(props.values);
const requiredFields = ['name', 'region', 'location', 'country'];
const requiredFields = ['name'];
const [openModalDeleteInstallation, setOpenModalDeleteInstallation] =
useState(false);
const [pendingPreset, setPendingPreset] = useState<string | null>(null);
@ -93,16 +95,25 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
return [value.trim()];
};
const DeviceTypes = [
{ id: 3, name: 'Growatt' },
{ id: 4, name: 'Sinexcel' }
];
const isSodistorePro = props.values.product === 5;
const DeviceTypes = isSodistorePro
? [{ id: 4, name: 'inesco 12K - WR Hybrid' } as const]
: SODIOHOME_DEVICE_TYPES;
// Preset state — initializes from persisted installationModel, empty for legacy
const [selectedPreset, setSelectedPreset] = useState<string>(
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>(() => {
if (presetConfig) {
@ -130,8 +141,32 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
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 newConfig = INSTALLATION_PRESETS[newPreset];
const newConfig = getPresetsForDevice(formValues.device)[newPreset];
if (!newConfig) return;
// 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 newConfig = INSTALLATION_PRESETS[newPreset];
const newConfig = getPresetsForDevice(formValues.device)[newPreset];
if (!newConfig) return;
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 updated = [...inverterSerialNumbers];
updated[invIdx] = value;
@ -272,10 +339,28 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
const handleChange = (e) => {
const { name, value } = e.target;
setFormValues({
...formValues,
[name]: value
});
const updated = { ...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 = () => {
@ -533,27 +618,45 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</div>
<div>
<TextField
label={<FormattedMessage id="region" defaultMessage="Region" />}
name="region"
value={formValues.region}
label={<FormattedMessage id="street" defaultMessage="Street" />}
name="street"
value={formValues.street || ''}
onChange={handleChange}
variant="outlined"
fullWidth
required={canEdit}
error={canEdit && formValues.region === ''}
inputProps={{ readOnly: !canEdit }}
/>
</div>
<div>
<TextField
label={<FormattedMessage id="location" defaultMessage="Location" />}
name="location"
value={formValues.location}
label={<FormattedMessage id="postCode" defaultMessage="Postcode" />}
name="postCode"
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}
variant="outlined"
fullWidth
required={canEdit}
error={canEdit && formValues.location === ''}
inputProps={{ readOnly: !canEdit }}
/>
</div>
@ -561,12 +664,21 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
<TextField
label={<FormattedMessage id="country" defaultMessage="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}
variant="outlined"
fullWidth
required={canEdit}
error={canEdit && formValues.country === ''}
inputProps={{ readOnly: !canEdit }}
/>
</div>
@ -583,6 +695,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</div>
)}
{!isSodistorePro && (
<div>
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
@ -602,6 +715,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</Select>
</FormControl>
</div>
)}
<div>
<Autocomplete
@ -614,7 +728,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
onInputChange={(_e, val) =>
setFormValues({ ...formValues, networkProvider: val || '' })
}
disabled={!canEdit && !isPartner}
disabled={!canEdit}
loading={loadingProviders}
renderInput={(params) => (
<TextField
@ -636,6 +750,18 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
/>
</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>
<FormControl fullWidth sx={{ marginLeft: 1, marginTop: 1, marginBottom: 1, width: 440 }}>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
@ -652,7 +778,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
const val = e.target.value as string;
setFormValues({ ...formValues, externalEms: val });
}}
inputProps={{ readOnly: !canEdit && !isPartner }}
inputProps={{ readOnly: !canEdit }}
>
<MenuItem value="No"><FormattedMessage id="emsNo" defaultMessage="No" /></MenuItem>
<MenuItem value="Solar Manager">Solar Manager</MenuItem>
@ -673,7 +799,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
onChange={handleChange}
variant="outlined"
fullWidth
inputProps={{ readOnly: !canEdit && !isPartner }}
inputProps={{ readOnly: !canEdit }}
/>
</div>
)}
@ -696,13 +822,64 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
<FormattedMessage id="installationSetup" defaultMessage="Installation Setup" />
</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>
<FormControl sx={{ m: 1, width: '50ch' }}>
<InputLabel
shrink
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>
<Select
value={selectedPreset}
@ -714,7 +891,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
<MenuItem value="" disabled>
<em><FormattedMessage id="selectModel" defaultMessage="Select model..." /></em>
</MenuItem>
{Object.keys(INSTALLATION_PRESETS).map((name) => (
{Object.keys(getPresetsForDevice(formValues.device)).map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
@ -722,12 +899,25 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
</Select>
</FormControl>
</div>
)}
<div>
<TextField
label={<FormattedMessage id="installationSerialNumber" defaultMessage="Installation Serial Number" />}
name="serialNumber"
value={formValues.serialNumber}
label={<FormattedMessage id="inverterFirmwareVersion" defaultMessage="Inverter Firmware Version" />}
name="inverterFirmwareVersion"
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}
variant="outlined"
fullWidth
@ -746,7 +936,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
return (
<Accordion
key={`inv-${invIdx}`}
defaultExpanded={!invSn}
defaultExpanded={false}
sx={{ mt: 1 }}
>
<AccordionSummary
@ -802,7 +992,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
return (
<Accordion
key={`cl-${invIdx}-${clIdx}`}
defaultExpanded={true}
defaultExpanded={false}
sx={{ mt: 1 }}
>
<AccordionSummary
@ -870,7 +1060,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
<div>
<TextField
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"
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
export type PresetConfig = number[][];
// 3D array: [inverter][cluster][batteryIndex] = serialNumber
export type BatterySnTree = string[][][];
export const INSTALLATION_PRESETS: Record<string, PresetConfig> = {
'sodistore home 9': [[1, 1]],
'sodistore home 18': [[2, 2]],
'sodistore home 27': [[2, 2], [1, 1]],
'sodistore home 36': [[2, 2], [2, 2]],
// 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 18': [[2, 2]],
'sodistore home 27': [[2, 2], [1, 1]],
'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 => {

View File

@ -29,6 +29,7 @@ import BatteryView from '../BatteryView/BatteryView';
import Configuration from '../Configuration/Configuration';
import PvView from '../PvView/PvView';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
interface singleInstallationProps {
current_installation?: I_Installation;
@ -150,7 +151,8 @@ function Installation(props: singleInstallationProps) {
return false;
}
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]);
await timeout(2000);
@ -381,7 +383,7 @@ function Installation(props: singleInstallationProps) {
{loading &&
currentTab != 'information' &&
currentTab != 'history' &&
currentTab != 'manage' &&
// currentTab != 'manage' &&
currentTab != 'log' &&
currentTab != 'installationTickets' && (
<Container
@ -538,7 +540,7 @@ function Installation(props: singleInstallationProps) {
/>
)}
{currentUser.userType == UserType.admin && (
{/* {currentUser.userType == UserType.admin && (
<Route
path={routes.manage}
element={
@ -550,7 +552,7 @@ function Installation(props: singleInstallationProps) {
</AccessContextProvider>
}
/>
)}
)} */}
{currentUser.userType == UserType.admin && (
<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
path={'*'}
element={<Navigate to={routes.live}></Navigate>}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@ import { UserType } from '../../../interfaces/UserTypes';
import HistoryOfActions from '../History/History';
import BuildIcon from '@mui/icons-material/Build';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
import AccessContextProvider from '../../../contexts/AccessContextProvider';
import Access from '../ManageAccess/Access';
@ -105,7 +106,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
setLoading(false);
console.log('NUMBER OF FILES=' + Object.keys(res).length);
console.log('res=', res);
console.log('res= [S3 credentials hidden]');
while (continueFetching.current) {
for (const timestamp of Object.keys(res)) {
@ -114,7 +115,8 @@ function SalidomoInstallation(props: singleInstallationProps) {
return false;
}
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
setValues(res[timestamp]);
@ -323,7 +325,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
</div>
{loading &&
currentTab != 'information' &&
currentTab != 'manage' &&
// currentTab != 'manage' &&
currentTab != 'history' &&
currentTab != 'log' &&
currentTab != 'installationTickets' && (
@ -416,7 +418,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
/>
)}
{currentUser.userType == UserType.admin && (
{/* {currentUser.userType == UserType.admin && (
<Route
path={routes.manage}
element={
@ -428,7 +430,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
</AccessContextProvider>
}
/>
)}
)} */}
{currentUser.userType == UserType.admin && (
<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
path={'*'}
element={<Navigate to={routes.batteryview}></Navigate>}

View File

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

View File

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

View File

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

View File

@ -29,6 +29,7 @@ import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
import Overview from '../Overview/overview';
import WeeklyReport from './WeeklyReport';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
interface singleInstallationProps {
current_installation?: I_Installation;
@ -50,7 +51,9 @@ function SodioHomeInstallation(props: singleInstallationProps) {
const s3Bucket =
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 { currentUser } = context;
@ -181,7 +184,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
return false;
}
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]);
await timeout(2000);
@ -471,7 +475,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
</div>
{loading &&
currentTab != 'information' &&
currentTab != 'manage' &&
// currentTab != 'manage' &&
currentTab != 'history' &&
currentTab != 'log' &&
currentTab != 'report' &&
@ -582,7 +586,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
/>
)}
{currentUser.userType == UserType.admin && (
{/* {currentUser.userType == UserType.admin && (
<Route
path={routes.manage}
element={
@ -594,7 +598,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
</AccessContextProvider>
}
/>
)}
)} */}
<Route
path={routes.overview}
@ -603,6 +607,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
s3Credentials={s3Credentials}
id={props.current_installation.id}
device={props.current_installation.device}
product={props.current_installation.product}
connected={connected}
loading={loading}
/>
@ -615,6 +620,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
element={
<WeeklyReport
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
path={'*'}
element={<Navigate to={routes.live}></Navigate>}

View File

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

View File

@ -17,48 +17,57 @@ import { Close as CloseIcon } from '@mui/icons-material';
import { I_Installation } from 'src/interfaces/InstallationTypes';
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
import { FormattedMessage } from 'react-intl';
import { INSTALLATION_PRESETS } from '../Information/installationSetupUtils';
import { getPresetsForDevice, SODIOHOME_DEVICE_TYPES } from '../Information/installationSetupUtils';
interface SodistorehomeInstallationFormPros {
cancel: () => void;
submit: () => void;
parentid: number;
product?: number;
}
function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros) {
const theme = useTheme();
const [open, setOpen] = useState(true);
const isSodistorePro = props.product === 5;
const [formValues, setFormValues] = useState<Partial<I_Installation>>({
name: '',
region: '',
location: '',
country: '',
vpnIp: '',
installationModel: '',
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 = [
{ id: 3, name: 'Growatt' },
{ id: 4, name: 'Sinexcel' }
];
const DeviceTypes = isSodistorePro
? [{ id: 4, name: 'inesco 12K - WR Hybrid' }]
: SODIOHOME_DEVICE_TYPES;
const installationContext = useContext(InstallationsContext);
const { createInstallation, loading, setLoading, error, setError } =
installationContext;
const handleChange = (e) => {
const { name, value } = e.target;
const updated = { ...formValues, [name]: value };
setFormValues({
...formValues,
[name]: value
});
// Reset preset when device type changes if current preset is invalid
if (name === 'device' && !isSodistorePro) {
const newDevicePresets = getPresetsForDevice(Number(value));
if (formValues.installationModel && !newDevicePresets[formValues.installationModel]) {
updated.installationModel = '';
}
}
setFormValues(updated);
};
const handleSubmit = async (e) => {
setLoading(true);
formValues.parentId = props.parentid;
formValues.product = 2;
formValues.product = props.product ?? 2;
if (isSodistorePro) {
formValues.installationModel = inverterCount;
}
const responseData = await createInstallation(formValues);
props.submit();
};
@ -72,6 +81,9 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
return false;
}
}
if (isSodistorePro && (!inverterCount || parseInt(inverterCount, 10) < 1)) {
return false;
}
return true;
};
@ -127,42 +139,6 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
error={formValues.name === ''}
/>
</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>
<TextField
label={<FormattedMessage id="VpnIp" defaultMessage="VpnIp" />}
@ -174,10 +150,71 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
/>
</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>
<FormControl
fullWidth
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 === ''}
sx={{
marginTop: 1,
@ -201,7 +238,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
value={formValues.installationModel || ''}
onChange={handleChange}
>
{Object.keys(INSTALLATION_PRESETS).map((name) => (
{Object.keys(getPresetsForDevice(formValues.device as number)).map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
@ -209,40 +246,8 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
</Select>
</FormControl>
</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>
<div

View File

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

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import {
Avatar,
Box,
@ -6,15 +6,19 @@ import {
Card,
CardContent,
CardHeader,
Chip,
Divider,
LinearProgress,
TextField,
Typography
} from '@mui/material';
import PersonIcon from '@mui/icons-material/Person';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
import DocumentList from 'src/components/DocumentList';
interface CommentThreadProps {
ticketId: number;
@ -31,21 +35,67 @@ function CommentThread({
}: CommentThreadProps) {
const [body, setBody] = useState('');
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(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
const handleSubmit = () => {
if (!body.trim()) return;
const handleSubmit = async () => {
if (!body.trim() && selectedFiles.length === 0) return;
setSubmitting(true);
axiosConfig
.post('/AddTicketComment', { ticketId, body })
.then(() => {
setBody('');
onCommentAdded();
})
.finally(() => setSubmitting(false));
try {
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('');
setSelectedFiles([]);
setRefreshKey((k) => k + 1);
onCommentAdded();
} finally {
setSubmitting(false);
}
};
return (
@ -100,6 +150,7 @@ function CommentThread({
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{comment.body}
</Typography>
<DocumentList ticketCommentId={comment.id} refreshKey={refreshKey} />
</Box>
</Box>
);
@ -107,25 +158,57 @@ function CommentThread({
<Divider />
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
fullWidth
multiline
minRows={2}
maxRows={4}
placeholder="Add a comment..."
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<Button
variant="contained"
onClick={handleSubmit}
disabled={submitting || !body.trim()}
sx={{ alignSelf: 'flex-end' }}
>
<FormattedMessage id="addComment" defaultMessage="Add" />
</Button>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
fullWidth
multiline
minRows={2}
maxRows={4}
placeholder="Add a comment..."
value={body}
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
variant="contained"
onClick={handleSubmit}
disabled={submitting || uploading || (!body.trim() && selectedFiles.length === 0)}
>
<FormattedMessage id="addComment" defaultMessage="Add" />
</Button>
</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>
</Card>

View File

@ -1,8 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Autocomplete,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
@ -10,10 +12,13 @@ import {
DialogTitle,
FormControl,
InputLabel,
LinearProgress,
MenuItem,
Select,
TextField
TextField,
Typography
} from '@mui/material';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
@ -37,7 +42,8 @@ const productOptions = [
{ value: 1, label: 'Salidomo' },
{ value: 2, label: 'Sodistore Home' },
{ 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 }[]> = {
@ -47,7 +53,7 @@ const deviceOptionsByProduct: Record<number, { value: number; label: string }[]>
],
2: [
{ 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 [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
const [customSubCategory, setCustomSubCategory] = useState('');
const [customCategory, setCustomCategory] = useState('');
@ -200,16 +238,17 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
setDescription('');
setCustomSubCategory('');
setCustomCategory('');
setSelectedFiles([]);
setError('');
};
const handleSubmit = () => {
const handleSubmit = async () => {
if (!subject.trim()) return;
setSubmitting(true);
setError('');
axiosConfig
.post('/CreateTicket', {
try {
const res = await axiosConfig.post('/CreateTicket', {
subject,
description,
installationId: selectedInstallation?.id ?? null,
@ -218,14 +257,40 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
subCategory: isOtherCategory ? 0 : subCategory,
customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null,
customCategory: isOtherCategory ? customCategory || null : null
})
.then(() => {
resetForm();
onCreated();
onClose();
})
.catch(() => setError('Failed to create ticket.'))
.finally(() => setSubmitting(false));
});
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();
onCreated();
onClose();
} catch {
setError('Failed to create ticket.');
} finally {
setSubmitting(false);
}
};
const availableSubCategories = subCategoriesByCategory[category] ?? [];
@ -441,6 +506,44 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
fullWidth
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>
<DialogActions>
<Button onClick={onClose}>

View File

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

View File

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

View File

@ -59,17 +59,18 @@ function TreeInformation(props: TreeInformationProps) {
fetchAllInstallations
} = installationContext;
const [product, setProduct] = useState('Salimax');
const [product, setProduct] = useState('SodistoreHome');
const handleChangeInstallationChoice = (e) => {
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> = {
'SodistoreHome': 'Sodistore Home',
'SodistoreMax': 'Sodistore Max',
'SodistoreGrid': 'Sodistore Grid'
'SodistoreGrid': 'Sodistore Grid',
'SodistorePro': 'Sodistore Pro'
};
const isMobile = window.innerWidth <= 1490;
@ -345,11 +346,12 @@ function TreeInformation(props: TreeInformationProps) {
/>
)}
{openModalInstallation && product == 'SodistoreHome' && (
{openModalInstallation && (product == 'SodistoreHome' || product == 'SodistorePro') && (
<SodiostorehomeInstallationForm
cancel={handleFormCancel}
submit={handleInstallationFormSubmit}
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 {
Alert,
Autocomplete,
Box,
CircularProgress,
FormControl,
@ -10,6 +11,7 @@ import {
Modal,
Select,
TextField,
Typography,
useTheme
} from '@mui/material';
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 { 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 {
cancel: () => void;
submit: () => void;
@ -32,7 +44,6 @@ function userForm(props: userFormProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [errormessage, setErrorMessage] = useState(intl.formatMessage({ id: 'errorOccured' }));
const [openInstallation, setOpenInstallation] = useState(false);
const [openFolder, setOpenFolder] = useState(false);
const [formValues, setFormValues] = useState<Partial<InnovEnergyUser>>({
name: '',
@ -41,9 +52,7 @@ function userForm(props: userFormProps) {
});
const requiredFields = ['name', 'email'];
const [selectedFolderNames, setSelectedFolderNames] = useState<string[]>([]);
const [selectedInstallationNames, setSelectedInstallationNames] = useState<
string[]
>([]);
const [selectedInstallations, setSelectedInstallations] = useState<I_Installation[]>([]);
const UserTypes = ['Client', 'Partner', 'Admin'];
@ -72,22 +81,14 @@ function userForm(props: userFormProps) {
setLoading(true);
try {
// fetch product 0
const res0 = await axiosConfig.get(
`/GetAllInstallationsFromProduct?product=0`
const products = [0, 1, 2, 3, 4, 5];
const responses = await Promise.all(
products.map((p) => axiosConfig.get(`/GetAllInstallationsFromProduct?product=${p}`))
);
const installations0 = res0.data;
// fetch product 1
const res1 = await axiosConfig.get(
`/GetAllInstallationsFromProduct?product=3`
const combined = responses.flatMap((res, idx) =>
res.data.map((inst: I_Installation) => ({ ...inst, product: products[idx] }))
);
const installations1 = res1.data;
// aggregate
const combined = [...installations0, ...installations1];
// update
setInstallations(combined);
} catch (err) {
if (err.response && err.response.status === 401) {
@ -98,6 +99,15 @@ function userForm(props: userFormProps) {
}
}, [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(() => {
fetchFolders();
fetchInstallations();
@ -114,10 +124,6 @@ function userForm(props: userFormProps) {
setSelectedFolderNames(event.target.value);
};
const handleInstallationChange = (event) => {
setSelectedInstallationNames(event.target.value);
};
const isMobile = window.innerWidth <= 1490;
const handleSubmit = async (e) => {
@ -151,11 +157,7 @@ function userForm(props: userFormProps) {
});
}
for (const installationName of selectedInstallationNames) {
const installation = installations.find(
(installation) => installation.name === installationName
);
for (const installation of selectedInstallations) {
await axiosConfig
.post(
`/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 = () => {
setOpenFolder(true);
};
@ -343,55 +337,43 @@ function userForm(props: userFormProps) {
</div>
<div>
<FormControl fullWidth sx={{ marginTop: 2, width: 390 }}>
<InputLabel
sx={{
fontSize: 14,
backgroundColor: 'white'
}}
>
<FormattedMessage
id="grantAccessToInstallations"
defaultMessage="Grant access to installations"
/>
</InputLabel>
<Select
multiple
value={selectedInstallationNames}
onChange={handleInstallationChange}
open={openInstallation}
onClose={handleCloseInstallation}
onOpen={handleOpenInstallation}
renderValue={(selected) => (
<div>
{selected.map((installation) => (
<span key={installation}>{installation}, </span>
))}
</div>
)}
>
{installations.map((installation) => (
<MenuItem key={installation.id} value={installation.name}>
{installation.name}
</MenuItem>
))}
<Button
sx={{
marginLeft: '150px',
marginTop: '10px',
backgroundColor: theme.colors.primary.main,
color: 'white',
'&:hover': {
backgroundColor: theme.colors.primary.dark
},
padding: '6px 8px'
<Autocomplete<I_Installation, true, false, false>
multiple
options={sortedInstallations}
groupBy={(option) => PRODUCT_NAMES[option.product] || 'Unknown'}
getOptionLabel={(option) => option.name}
value={selectedInstallations}
onChange={(_event, newValue) => setSelectedInstallations(newValue)}
isOptionEqualToValue={(option, value) => option.id === value.id}
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) => (
<TextField
{...params}
label={intl.formatMessage({ id: 'grantAccessToInstallations' })}
placeholder={intl.formatMessage({ id: 'searchInstallations' })}
InputLabelProps={{
...params.InputLabelProps,
sx: { fontSize: 14, backgroundColor: 'white' }
}}
onClick={handleCloseInstallation}
>
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
</Select>
</FormControl>
/>
)}
sx={{ mt: 1 }}
/>
</div>
<div>

View File

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

View File

@ -34,6 +34,9 @@ const InstallationsContextProvider = ({
const [sodistoreGridInstallations, setSodistoreGridInstallations] = useState<
I_Installation[]
>([]);
const [sodistoreProInstallations, setSodistoreProInstallations] = useState<
I_Installation[]
>([]);
const [foldersAndInstallations, setFoldersAndInstallations] = useState([]);
const [loading, setLoading] = 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);
setSalimax_Or_Sodistore_Installations(updatedSalimax);
setSodiohomeInstallations(updatedSodiohome);
setSodistoreGridInstallations(updatedSodistoreGrid);
setSodistoreProInstallations(updatedSodistorePro);
// Clear the pending updates after applying
pendingUpdates.current = {};
@ -116,7 +133,8 @@ const InstallationsContextProvider = ({
salidomoInstallations,
salimax_or_sodistore_Installations,
sodiohomeInstallations,
sodistoreGridInstallations
sodistoreGridInstallations,
sodistoreProInstallations
]);
useEffect(() => {
@ -193,6 +211,8 @@ const InstallationsContextProvider = ({
if (product === 2) {
setSodiohomeInstallations(res.data);
} else if (product === 5) {
setSodistoreProInstallations(res.data);
} else if (product === 1) {
setSalidomoInstallations(res.data);
} else if (product === 0 || product === 3) {
@ -418,6 +438,7 @@ const InstallationsContextProvider = ({
salidomoInstallations,
sodiohomeInstallations,
sodistoreGridInstallations,
sodistoreProInstallations,
foldersAndInstallations,
fetchAllInstallations,
fetchAllFoldersAndInstallations,
@ -445,6 +466,7 @@ const InstallationsContextProvider = ({
salidomoInstallations,
sodiohomeInstallations,
sodistoreGridInstallations,
sodistoreProInstallations,
foldersAndInstallations,
loading,
error,

View File

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

View File

@ -30,6 +30,7 @@ export interface overviewInterface {
overview: chartInfoInterface;
ACLoad: chartInfoInterface;
DCLoad: chartInfoInterface;
batteryVoltage: chartInfoInterface;
}
export interface chartAggregatedDataInterface {
@ -53,6 +54,7 @@ export interface chartDataInterface {
dcBusVoltage: { name: string; data: number[] };
ACLoad: { name: string; data: number[] };
DCLoad: { name: string; data: number[] };
batteryVoltage: { name: string; data: number[] };
}
export interface BatteryDataInterface {
@ -86,7 +88,7 @@ export const transformInputToBatteryViewDataJson = async (
}> => {
const prefixes = ['', 'k', 'M', 'G', 'T'];
const MAX_NUMBER = 9999999;
const isSodioHome = product === 2;
const isSodioHome = product === 2 || product === 5;
const categories = isSodioHome
? ['Soc', 'Power', 'Voltage', 'Current', 'Soh']
: ['Soc', 'Temperature', 'Power', 'Voltage', 'Current'];
@ -169,7 +171,7 @@ export const transformInputToBatteryViewDataJson = async (
);
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] * 100000);
//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) => {
pathsToSave.forEach((path) => {
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 categoryFieldMap = {
const hasDevices = !!inv?.Devices;
// Sinexcel field suffixes differ from Growatt for Voltage/Current
const categoryFieldMapGrowatt = {
Soc: 'Soc',
Power: 'Power',
Voltage: 'Voltage',
Current: 'Current',
Soh: 'Soh'
};
const categoryFieldMapSinexcel = {
Soc: 'Soc',
Power: 'Power',
Voltage: 'PackTotalVoltage',
Current: 'PackTotalCurrent',
Soh: 'Soh'
};
for (let j = 0; j < pathsToSave.length; j++) {
const batteryIndex = j + 1; // Battery1, Battery2, ...
categories.forEach((category) => {
const fieldName = `Battery${batteryIndex}${categoryFieldMap[category]}`;
const value = inv[fieldName];
let value: number | undefined;
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 < chartOverview[category].min) {
@ -393,7 +421,7 @@ export const transformInputToDailyDataJson = async (
// custom fallback logic to handle differences between Growatt and Sinexcel.
// Growatt has: Battery1AmbientTemperature, GridPower, PvPower
// Sinexcel has: Battery1Temperature, TotalGridPower (meter may be offline), PvPower1-4
const pathsToSearch = product == 2
const pathsToSearch = (product == 2 || product == 5)
? [
'SODIOHOME_SOC',
'SODIOHOME_TEMPERATURE',
@ -402,7 +430,8 @@ export const transformInputToDailyDataJson = async (
'SODIOHOME_PV_POWER',
null, // dcBusVoltage not available for SodioHome
'SODIOHOME_CONSUMPTION',
null // DCLoad not available for SodioHome
null, // DCLoad not available for SodioHome
'SODIOHOME_BATTERY_VOLTAGE'
]
: [
'Battery.Soc',
@ -412,7 +441,8 @@ export const transformInputToDailyDataJson = async (
'PvOnDc',
'DcDc.Dc.Link.Voltage',
'LoadOnAcGrid.Power.Active',
'LoadOnDc.Power'
'LoadOnDc.Power',
null // batteryVoltage not available for Salimax
];
const categories = [
'soc',
@ -422,18 +452,20 @@ export const transformInputToDailyDataJson = async (
'pvProduction',
'dcBusVoltage',
'ACLoad',
'DCLoad'
'DCLoad',
'batteryVoltage'
];
const chartData: chartDataInterface = {
soc: { name: 'State Of Charge', data: [] },
soc: { name: 'Battery SOC', data: [] },
temperature: { name: 'Battery Temperature', data: [] },
dcPower: { name: 'Battery Power', data: [] },
gridPower: { name: 'Grid Power', data: [] },
pvProduction: { name: 'Pv Production', data: [] },
pvProduction: { name: 'PV Power', data: [] },
dcBusVoltage: { name: 'DC Bus Voltage', data: [] },
ACLoad: { name: 'AC Load', data: [] },
DCLoad: { name: 'DC Load', data: [] }
DCLoad: { name: 'DC Load', data: [] },
batteryVoltage: { name: 'Battery Voltage', data: [] }
};
const chartOverview: overviewInterface = {
@ -446,7 +478,8 @@ export const transformInputToDailyDataJson = async (
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
overview: { 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) => {
@ -504,7 +537,8 @@ export const transformInputToDailyDataJson = async (
Object.keys(results[i]).length - 1
];
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;
// eslint-disable-next-line @typescript-eslint/no-loop-func
pathsToSearch.forEach((path) => {
@ -516,8 +550,8 @@ export const transformInputToDailyDataJson = async (
let value: number | undefined = undefined;
if (product === 2) {
// SodioHome: use top-level aggregated values (Sinexcel multi-inverter)
if (product === 2 || product === 5) {
// SodioHome/SodistorePro: use top-level aggregated values (Sinexcel multi-inverter)
const inv = result?.InverterRecord;
if (inv) {
switch (category_index) {
@ -539,6 +573,9 @@ export const transformInputToDailyDataJson = async (
case 6: // consumption
value = inv.TotalLoadPower ?? inv.ConsumptionPower;
break;
case 8: // battery voltage
value = inv.AvgBatteryVoltage ?? inv.Battery1Voltage;
break;
}
}
} else if (category_index === 4) {
@ -609,20 +646,24 @@ export const transformInputToDailyDataJson = async (
'(' + prefixes[chartOverview['ACLoad'].magnitude] + 'W' + ')';
chartOverview.DCLoad.unit =
'(' + prefixes[chartOverview['DCLoad'].magnitude] + 'W' + ')';
chartOverview.batteryVoltage.unit = '(V)';
chartOverview.overview = {
magnitude: Math.max(
chartOverview['gridPower'].magnitude,
chartOverview['pvProduction'].magnitude
chartOverview['pvProduction'].magnitude,
chartOverview['ACLoad'].magnitude
),
unit: '(kW)',
min: Math.min(
chartOverview['gridPower'].min,
chartOverview['pvProduction'].min
chartOverview['pvProduction'].min,
chartOverview['ACLoad'].min
),
max: Math.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);
if (res !== FetchResult.notAvailable && res !== FetchResult.tryLater) {
//console.log('Successfully fetched ' + timestampToFetch);
console.log('Successfully fetched ' + timestampToFetch);
return res;
}
} catch (err) {
console.error('Error fetching data:', err);
}
}
console.warn('Failed to fetch timestamp ' + startUnixTime.ticks);
return null;
};
@ -719,7 +761,8 @@ export const transformInputToAggregatedDataJson = async (
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
overview: { 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) => {
@ -735,7 +778,7 @@ export const transformInputToAggregatedDataJson = async (
const timestampPromises = [];
while (currentDay.isBefore(end_date)) {
const dateFormat = product === 2
const dateFormat = (product === 2 || product === 5)
? currentDay.format('DDMMYYYY')
: currentDay.format('YYYY-MM-DD');
timestampPromises.push(
@ -745,7 +788,7 @@ export const transformInputToAggregatedDataJson = async (
}
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;
for (let i = 0; i < results.length; i++) {

View File

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

View File

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

View File

@ -6,7 +6,15 @@
"alarms": "Alarme",
"applyChanges": "Änderungen speichern",
"country": "Land",
"street": "Strasse",
"postCode": "PLZ",
"city": "Ort",
"canton": "Kanton",
"distributionPartner": "Vertriebspartner",
"inverterFirmwareVersion": "Wechselrichter-Firmware-Version",
"batteryFirmwareVersion": "Batterie-Firmware-Version",
"networkProvider": "Netzbetreiber",
"emailAddress": "E-Mail-Adresse",
"createNewFolder": "Neuer Ordner",
"createNewUser": "Neuer Benutzer",
"customerName": "Kundenname",
@ -80,6 +88,9 @@
"emsOther": "Andere",
"generalInfo": "Allgemeine Informationen",
"installationSetup": "Installationseinrichtung",
"couplingType": "AC/DC-Kopplung",
"couplingAC": "AC-gekoppelt",
"couplingDC": "DC-gekoppelt",
"selectModel": "Modell auswählen...",
"inverterN": "Wechselrichter {n}",
"clusterN": "Cluster {n}",
@ -109,6 +120,7 @@
"deleteFolder": "Ordner löschen",
"grantAccessToFolders": "Zugriff auf Ordner gewähren",
"grantAccessToInstallations": "Zugriff auf Installationen gewähren",
"searchInstallations": "Installationen suchen...",
"cannotloadloggingdata": "Log Daten können nicht geladen werden",
"grantedAccessToUsers": "Den Benutzern wurde den Zugriff gewährt",
"unableToGrantAccess": "Der Zugriff kann nicht gewährt werden",
@ -121,7 +133,6 @@
"reportTitle": "Wöchentlicher Leistungsbericht",
"weeklyInsights": "Wöchentliche Einblicke",
"missingDaysWarning": "Daten verfügbar für {available}/{expected} Tage. Fehlend: {dates}",
"weeklySavings": "Ihre Einsparungen diese Woche",
"solarEnergyUsed": "Energie gespart",
"solarStayedHome": "Solar + Batterie, nicht vom Netz",
"daysOfYourUsage": "Tage Ihres Verbrauchs",
@ -129,10 +140,8 @@
"atCHFRate": "bei 0,39 CHF/kWh Ø",
"solarCoverage": "Energieunabhängigkeit",
"fromSolarSub": "aus eigenem Solar + Batterie System",
"avgDailyConsumption": "Ø Tagesverbrauch",
"batteryEfficiency": "Batterieeffizienz",
"batteryEffSub": "Entladung vs. Ladung",
"weeklySummary": "Wöchentliche Zusammenfassung",
"metric": "Kennzahl",
"thisWeek": "Diese Woche",
"change": "Änderung",
@ -142,11 +151,12 @@
"gridExport": "Netzeinspeisung",
"batteryInOut": "Batterie Laden / Entladen",
"dailyBreakdown": "Tägliche Aufschlüsselung",
"prevWeek": "(Vorwoche)",
"sendReport": "Bericht senden",
"generatingReport": "Wochenbericht wird erstellt...",
"reportSentTo": "Bericht gesendet an {email}",
"reportSendError": "Senden fehlgeschlagen. Bitte überprüfen Sie die E-Mail-Adresse und versuchen Sie es erneut.",
"refreshReport": "Bericht aktualisieren",
"refreshing": "Aktualisierung...",
"ok": "Ok",
"grantedAccessToUser": "Zugriff für Benutzer {name} gewährt",
"proceed": "Fortfahren",
@ -165,7 +175,7 @@
"dailySummary": "Tagesübersicht",
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
"currentWeekHint": "Aktuelle Woche (Mogestern)",
"currentWeekHint": "Letzte 7 Tage",
"intradayChart": "Tagesverlauf Energiefluss",
"batteryPower": "Batterieleistung",
"batterySoCLabel": "Batterie SoC",
@ -179,21 +189,20 @@
"yearlyReportTitle": "Jährlicher Leistungsbericht",
"monthlyInsights": "Monatliche Einblicke",
"yearlyInsights": "Jährliche Einblicke",
"monthlySavings": "Ihre Einsparungen diesen Monat",
"yearlySavings": "Ihre Einsparungen dieses Jahr",
"monthlySummary": "Monatliche Zusammenfassung",
"yearlySummary": "Jährliche Zusammenfassung",
"total": "Gesamt",
"weeksAggregated": "{count} Wochen aggregiert",
"monthsAggregated": "{count} Monate aggregiert",
"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.",
"availableForGeneration": "Zur Generierung verfügbar",
"generateMonth": "{month} {year} generieren ({count} Wochen)",
"generateYear": "{year} generieren ({count} Monate)",
"regenerateReport": "Neu generieren",
"generatingMonthly": "Wird generiert...",
"generatingYearly": "Wird generiert...",
"reportInProgress": "{month} (in Bearbeitung)",
"daysOfTotal": "{available} von {total} Tagen",
"monthsOfTotal": "{available} von {total} Monaten",
"monthlyAutoNote": "Der endgültige Bericht wird automatisch am 1. des nächsten Monats erstellt.",
"yearlyAutoNote": "Der endgültige Bericht wird automatisch am 2. Januar erstellt.",
"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",
"recentWeeklyReports": "Letzte Wochenberichte",
"ai_analyzing": "KI analysiert...",
@ -204,7 +213,7 @@
"demo_test_button": "KI-Diagnose",
"demo_hide_button": "KI-Diagnose ausblenden",
"demo_panel_title": "KI-Diagnose",
"demo_custom_group": "Benutzerdefiniert (kann Mistral KI verwenden)",
"demo_custom_group": "Benutzerdefiniert (kann KI verwenden)",
"demo_custom_option": "Benutzerdefinierten Alarm eingeben…",
"demo_custom_placeholder": "z.B. UnknownBatteryFault",
"demo_diagnose_button": "Diagnostizieren",
@ -545,6 +554,7 @@
"tourConfigurationContent": "Geräteeinstellungen für diese Installation anzeigen und ändern.",
"tourHistoryTitle": "Verlauf",
"tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert.",
"tourInstallationTicketsContent": "Support-Tickets für diese Installation anzeigen und verwalten — Probleme melden, Fortschritt verfolgen und KI-gestützte Diagnosen einsehen.",
"tickets": "Tickets",
"createTicket": "Ticket erstellen",
"subject": "Betreff",
@ -643,5 +653,38 @@
"timelineAiDiagnosisCompletedDesc": "KI-Diagnose abgeschlossen.",
"timelineAiDiagnosisFailedDesc": "KI-Diagnose fehlgeschlagen.",
"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",
"applyChanges": "Apply changes",
"country": "Country",
"street": "Street",
"postCode": "Postcode",
"city": "City",
"canton": "Canton",
"distributionPartner": "Distribution Partner",
"inverterFirmwareVersion": "Inverter Firmware Version",
"batteryFirmwareVersion": "Battery Firmware Version",
"networkProvider": "Network Provider",
"emailAddress": "Email Address",
"customerName": "Customer name",
"english": "English",
"german": "German",
@ -62,6 +70,9 @@
"emsOther": "Other",
"generalInfo": "General Info",
"installationSetup": "Installation Setup",
"couplingType": "AC/DC Coupling",
"couplingAC": "AC-coupled",
"couplingDC": "DC-coupled",
"selectModel": "Select model...",
"inverterN": "Inverter {n}",
"clusterN": "Cluster {n}",
@ -91,6 +102,7 @@
"deleteFolder": "Delete Folder",
"grantAccessToFolders": "Grant Access to Folders",
"grantAccessToInstallations": "Grant Access to Installations",
"searchInstallations": "Search installations...",
"cannotloadloggingdata": "Cannot load logging data",
"grantedAccessToUsers": "Granted access to users: ",
"unableToGrantAccess": "Unable to grant access to: ",
@ -103,7 +115,6 @@
"reportTitle": "Weekly Performance Report",
"weeklyInsights": "Weekly Insights",
"missingDaysWarning": "Data available for {available}/{expected} days. Missing: {dates}",
"weeklySavings": "Your Savings This Week",
"solarEnergyUsed": "Energy Saved",
"solarStayedHome": "solar + battery, not bought from grid",
"daysOfYourUsage": "days of your usage",
@ -111,10 +122,8 @@
"atCHFRate": "at 0.39 CHF/kWh avg.",
"solarCoverage": "Energy Independence",
"fromSolarSub": "from your own solar + battery system",
"avgDailyConsumption": "Avg Daily Consumption",
"batteryEfficiency": "Battery Efficiency",
"batteryEffSub": "discharge vs charge",
"weeklySummary": "Weekly Summary",
"metric": "Metric",
"thisWeek": "This Week",
"change": "Change",
@ -124,11 +133,12 @@
"gridExport": "Grid Export",
"batteryInOut": "Battery Charge / Discharge",
"dailyBreakdown": "Daily Breakdown",
"prevWeek": "(prev week)",
"sendReport": "Send Report",
"generatingReport": "Generating weekly report...",
"reportSentTo": "Report sent to {email}",
"reportSendError": "Failed to send. Please check the email address and try again.",
"refreshReport": "Refresh Report",
"refreshing": "Refreshing...",
"ok": "Ok",
"grantedAccessToUser": "Granted access to user {name}",
"proceed": "Proceed",
@ -147,7 +157,7 @@
"dailySummary": "Daily Summary",
"noDataForDate": "No data available for the selected date.",
"noHourlyData": "Hourly data not available for this day.",
"currentWeekHint": "Current week (Monyesterday)",
"currentWeekHint": "Last 7 days",
"intradayChart": "Intraday Power Flow",
"batteryPower": "Battery Power",
"batterySoCLabel": "Battery SoC",
@ -161,21 +171,20 @@
"yearlyReportTitle": "Annual Performance Report",
"monthlyInsights": "Monthly Insights",
"yearlyInsights": "Annual Insights",
"monthlySavings": "Your Savings This Month",
"yearlySavings": "Your Savings This Year",
"monthlySummary": "Monthly Summary",
"yearlySummary": "Annual Summary",
"total": "Total",
"weeksAggregated": "{count} weeks aggregated",
"monthsAggregated": "{count} months aggregated",
"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.",
"availableForGeneration": "Available for Generation",
"generateMonth": "Generate {month} {year} ({count} weeks)",
"generateYear": "Generate {year} ({count} months)",
"regenerateReport": "Regenerate",
"generatingMonthly": "Generating...",
"generatingYearly": "Generating...",
"reportInProgress": "{month} (in progress)",
"daysOfTotal": "{available} of {total} days",
"monthsOfTotal": "{available} of {total} months",
"monthlyAutoNote": "Final report will be automatically generated on the 1st of next month.",
"yearlyAutoNote": "Final report will be automatically generated on January 2nd.",
"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",
"recentWeeklyReports": "Recent Weekly Reports",
"ai_analyzing": "AI is analyzing...",
@ -186,7 +195,7 @@
"demo_test_button": "AI Diagnosis",
"demo_hide_button": "Hide AI Diagnosis",
"demo_panel_title": "AI Diagnosis",
"demo_custom_group": "Custom (may use Mistral AI)",
"demo_custom_group": "Custom (may use AI)",
"demo_custom_option": "Type custom alarm below…",
"demo_custom_placeholder": "e.g. UnknownBatteryFault",
"demo_diagnose_button": "Diagnose",
@ -293,6 +302,7 @@
"tourConfigurationContent": "View and modify device settings for this installation.",
"tourHistoryTitle": "History",
"tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when.",
"tourInstallationTicketsContent": "View and manage support tickets for this installation — report issues, track progress, and see AI-powered diagnostics.",
"tickets": "Tickets",
"createTicket": "Create Ticket",
"subject": "Subject",
@ -391,5 +401,38 @@
"timelineAiDiagnosisCompletedDesc": "AI diagnosis completed.",
"timelineAiDiagnosisFailedDesc": "AI diagnosis failed.",
"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",
"applyChanges": "Appliquer",
"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",
"emailAddress": "Adresse e-mail",
"createNewFolder": "Nouveau dossier",
"createNewUser": "Nouvel utilisateur",
"customerName": "Nom du client",
@ -74,6 +82,9 @@
"emsOther": "Autre",
"generalInfo": "Informations générales",
"installationSetup": "Configuration de l'installation",
"couplingType": "Couplage AC/DC",
"couplingAC": "Couplage AC",
"couplingDC": "Couplage DC",
"selectModel": "Sélectionner le modèle...",
"inverterN": "Onduleur {n}",
"clusterN": "Cluster {n}",
@ -103,6 +114,7 @@
"deleteFolder": "Supprimer le dossier",
"grantAccessToFolders": "Accorder l'accès aux dossiers",
"grantAccessToInstallations": "Accorder l'accès aux installations",
"searchInstallations": "Rechercher des installations...",
"cannotloadloggingdata": "Impossible de charger les données de journalisation",
"grantedAccessToUsers": "Accès accordé aux utilisateurs",
"unableToGrantAccess": "Impossible d'accorder l'accès à",
@ -115,7 +127,6 @@
"reportTitle": "Rapport de performance hebdomadaire",
"weeklyInsights": "Aperçus hebdomadaires",
"missingDaysWarning": "Données disponibles pour {available}/{expected} jours. Manquants : {dates}",
"weeklySavings": "Vos économies cette semaine",
"solarEnergyUsed": "Énergie économisée",
"solarStayedHome": "solaire + batterie, non achetée au réseau",
"daysOfYourUsage": "jours de votre consommation",
@ -123,10 +134,8 @@
"atCHFRate": "à 0,39 CHF/kWh moy.",
"solarCoverage": "Indépendance énergétique",
"fromSolarSub": "de votre système solaire + batterie",
"avgDailyConsumption": "Conso. quotidienne moy.",
"batteryEfficiency": "Efficacité de la batterie",
"batteryEffSub": "décharge vs charge",
"weeklySummary": "Résumé hebdomadaire",
"metric": "Métrique",
"thisWeek": "Cette semaine",
"change": "Variation",
@ -136,11 +145,12 @@
"gridExport": "Exportation réseau",
"batteryInOut": "Batterie Charge / Décharge",
"dailyBreakdown": "Répartition quotidienne",
"prevWeek": "(semaine précédente)",
"sendReport": "Envoyer le rapport",
"generatingReport": "Génération du rapport hebdomadaire...",
"reportSentTo": "Rapport envoyé à {email}",
"reportSendError": "Échec de l'envoi. Veuillez vérifier l'adresse e-mail et réessayer.",
"refreshReport": "Actualiser le rapport",
"refreshing": "Actualisation...",
"ok": "Ok",
"grantedAccessToUser": "Accès accordé à l'utilisateur {name}",
"proceed": "Continuer",
@ -159,7 +169,7 @@
"dailySummary": "Résumé du jour",
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
"noHourlyData": "Données horaires non disponibles pour ce jour.",
"currentWeekHint": "Semaine en cours (lunhier)",
"currentWeekHint": "7 derniers jours",
"intradayChart": "Flux d'énergie journalier",
"batteryPower": "Puissance batterie",
"batterySoCLabel": "SoC batterie",
@ -173,21 +183,20 @@
"yearlyReportTitle": "Rapport de performance annuel",
"monthlyInsights": "Aperçus mensuels",
"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",
"weeksAggregated": "{count} semaines agrégées",
"monthsAggregated": "{count} mois agrégés",
"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.",
"availableForGeneration": "Disponible pour génération",
"generateMonth": "Générer {month} {year} ({count} semaines)",
"generateYear": "Générer {year} ({count} mois)",
"regenerateReport": "Régénérer",
"generatingMonthly": "Génération en cours...",
"generatingYearly": "Génération en cours...",
"reportInProgress": "{month} (en cours)",
"daysOfTotal": "{available} sur {total} jours",
"monthsOfTotal": "{available} sur {total} mois",
"monthlyAutoNote": "Le rapport final sera généré automatiquement le 1er du mois prochain.",
"yearlyAutoNote": "Le rapport final sera généré automatiquement le 2 janvier.",
"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",
"recentWeeklyReports": "Derniers rapports hebdomadaires",
"ai_analyzing": "L'IA analyse...",
@ -198,7 +207,7 @@
"demo_test_button": "Diagnostic IA",
"demo_hide_button": "Masquer le diagnostic IA",
"demo_panel_title": "Diagnostic IA",
"demo_custom_group": "Personnalisé (peut utiliser Mistral IA)",
"demo_custom_group": "Personnalisé (peut utiliser IA)",
"demo_custom_option": "Saisir une alarme personnalisée…",
"demo_custom_placeholder": "ex. UnknownBatteryFault",
"demo_diagnose_button": "Diagnostiquer",
@ -545,6 +554,7 @@
"tourConfigurationContent": "Afficher et modifier les paramètres de l'appareil pour cette installation.",
"tourHistoryTitle": "Historique",
"tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand.",
"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",
"createTicket": "Créer un ticket",
"subject": "Objet",
@ -643,5 +653,38 @@
"timelineAiDiagnosisCompletedDesc": "Diagnostic IA terminé.",
"timelineAiDiagnosisFailedDesc": "Diagnostic IA échoué.",
"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",
"applyChanges": "Applica modifiche",
"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",
"emailAddress": "Indirizzo e-mail",
"customerName": "Nome cliente",
"english": "Inglese",
"german": "Tedesco",
@ -62,6 +70,9 @@
"emsOther": "Altro",
"generalInfo": "Informazioni generali",
"installationSetup": "Configurazione installazione",
"couplingType": "Accoppiamento AC/DC",
"couplingAC": "Accoppiamento AC",
"couplingDC": "Accoppiamento DC",
"selectModel": "Seleziona modello...",
"inverterN": "Inverter {n}",
"clusterN": "Cluster {n}",
@ -91,6 +102,7 @@
"deleteFolder": "Elimina cartella",
"grantAccessToFolders": "Concedi accesso alle cartelle",
"grantAccessToInstallations": "Concedi accesso alle installazioni",
"searchInstallations": "Cerca installazioni...",
"cannotloadloggingdata": "Impossibile caricare i dati di registro",
"grantedAccessToUsers": "Accesso concesso agli utenti: ",
"unableToGrantAccess": "Impossibile concedere l'accesso a: ",
@ -126,7 +138,6 @@
"reportTitle": "Rapporto settimanale sulle prestazioni",
"weeklyInsights": "Approfondimenti settimanali",
"missingDaysWarning": "Dati disponibili per {available}/{expected} giorni. Mancanti: {dates}",
"weeklySavings": "I tuoi risparmi questa settimana",
"solarEnergyUsed": "Energia risparmiata",
"solarStayedHome": "solare + batteria, non acquistata dalla rete",
"daysOfYourUsage": "giorni del tuo consumo",
@ -134,10 +145,8 @@
"atCHFRate": "a 0,39 CHF/kWh media",
"solarCoverage": "Indipendenza energetica",
"fromSolarSub": "dal proprio impianto solare + batteria",
"avgDailyConsumption": "Consumo medio giornaliero",
"batteryEfficiency": "Efficienza della batteria",
"batteryEffSub": "scarica vs carica",
"weeklySummary": "Riepilogo settimanale",
"metric": "Metrica",
"thisWeek": "Questa settimana",
"change": "Variazione",
@ -147,11 +156,12 @@
"gridExport": "Esportazione rete",
"batteryInOut": "Batteria Carica / Scarica",
"dailyBreakdown": "Ripartizione giornaliera",
"prevWeek": "(settimana precedente)",
"sendReport": "Invia rapporto",
"generatingReport": "Generazione del rapporto settimanale...",
"reportSentTo": "Rapporto inviato a {email}",
"reportSendError": "Invio fallito. Verificare l'indirizzo e-mail e riprovare.",
"refreshReport": "Aggiorna rapporto",
"refreshing": "Aggiornamento...",
"ok": "Ok",
"grantedAccessToUser": "Accesso concesso all'utente {name}",
"proceed": "Procedi",
@ -170,7 +180,7 @@
"dailySummary": "Riepilogo del giorno",
"noDataForDate": "Nessun dato disponibile per la data selezionata.",
"noHourlyData": "Dati orari non disponibili per questo giorno.",
"currentWeekHint": "Settimana corrente (lunieri)",
"currentWeekHint": "Ultimi 7 giorni",
"intradayChart": "Flusso energetico giornaliero",
"batteryPower": "Potenza batteria",
"batterySoCLabel": "SoC batteria",
@ -184,21 +194,20 @@
"yearlyReportTitle": "Rapporto annuale sulle prestazioni",
"monthlyInsights": "Approfondimenti mensili",
"yearlyInsights": "Approfondimenti annuali",
"monthlySavings": "I tuoi risparmi questo mese",
"yearlySavings": "I tuoi risparmi quest'anno",
"monthlySummary": "Riepilogo mensile",
"yearlySummary": "Riepilogo annuale",
"total": "Totale",
"weeksAggregated": "{count} settimane aggregate",
"monthsAggregated": "{count} mesi aggregati",
"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.",
"availableForGeneration": "Disponibile per la generazione",
"generateMonth": "Genera {month} {year} ({count} settimane)",
"generateYear": "Genera {year} ({count} mesi)",
"regenerateReport": "Rigenera",
"generatingMonthly": "Generazione in corso...",
"generatingYearly": "Generazione in corso...",
"reportInProgress": "{month} (in corso)",
"daysOfTotal": "{available} di {total} giorni",
"monthsOfTotal": "{available} di {total} mesi",
"monthlyAutoNote": "Il rapporto finale verrà generato automaticamente il 1° del mese prossimo.",
"yearlyAutoNote": "Il rapporto finale verrà generato automaticamente il 2 gennaio.",
"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",
"recentWeeklyReports": "Ultimi rapporti settimanali",
"ai_analyzing": "L'IA sta analizzando...",
@ -209,7 +218,7 @@
"demo_test_button": "Diagnosi IA",
"demo_hide_button": "Nascondi diagnosi IA",
"demo_panel_title": "Diagnosi IA",
"demo_custom_group": "Personalizzato (potrebbe usare Mistral IA)",
"demo_custom_group": "Personalizzato (potrebbe usare IA)",
"demo_custom_option": "Inserisci allarme personalizzato…",
"demo_custom_placeholder": "es. UnknownBatteryFault",
"demo_diagnose_button": "Diagnostica",
@ -545,6 +554,7 @@
"tourConfigurationContent": "Visualizza e modifica le impostazioni del dispositivo per questa installazione.",
"tourHistoryTitle": "Cronologia",
"tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando.",
"tourInstallationTicketsContent": "Visualizza e gestisci i ticket di supporto per questa installazione — segnala problemi, monitora i progressi e consulta le diagnosi IA.",
"tickets": "Ticket",
"createTicket": "Crea ticket",
"subject": "Oggetto",
@ -643,5 +653,38 @@
"timelineAiDiagnosisCompletedDesc": "Diagnosi IA completata.",
"timelineAiDiagnosisFailedDesc": "Diagnosi IA fallita.",
"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 { styled } from '@mui/material/styles';
import ExpandMoreTwoToneIcon from '@mui/icons-material/ExpandMoreTwoTone';
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
import { ThemeContext } from '../../../../theme/ThemeProvider';
import { FormattedMessage } from 'react-intl';
import '../../../../App.css';
import DataPrivacyDialog from '../../../../components/DataPrivacyDialog';
interface HeaderButtonsProps {
language: string;
@ -79,6 +81,7 @@ function HeaderMenu(props: HeaderButtonsProps) {
const setThemeName = themeContext;
const [darkState, setDarkState] = useState(false);
const [privacyOpen, setPrivacyOpen] = useState(false);
const handleThemeChange = () => {
setDarkState(!darkState);
@ -132,6 +135,20 @@ function HeaderMenu(props: HeaderButtonsProps) {
}
/>
</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>
</ListWrapper>
<div
@ -152,6 +169,10 @@ function HeaderMenu(props: HeaderButtonsProps) {
</MenuItem>
</Menu>
</div>
<DataPrivacyDialog
open={privacyOpen}
onClose={() => setPrivacyOpen(false)}
/>
</div>
);
}

View File

@ -170,7 +170,8 @@ function SidebarMenu() {
accessToSodistore,
accessToSalidomo,
accessToSodiohome,
accessToSodistoreGrid
accessToSodistoreGrid,
accessToSodistorePro
} = useContext(ProductIdContext);
return (
@ -185,37 +186,20 @@ function SidebarMenu() {
}
>
<SubMenuWrapper>
{accessToSalimax && (
{accessToSodiohome && (
<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>
)}
{accessToSodistore && (
<List component="div">
<ListItem component="div">
<Button
disableRipple
component={RouterLink}
onClick={closeSidebar}
to="/sodistore_installations"
to="/sodiohome_installations"
startIcon={<BrightnessLowTwoToneIcon />}
>
<Box sx={{ marginTop: '3px' }}>
<FormattedMessage
id="sodistore"
defaultMessage="Sodistore Max"
id="sodiohome"
defaultMessage="Sodistore Home"
/>
</Box>
</Button>
@ -223,20 +207,20 @@ function SidebarMenu() {
</List>
)}
{accessToSalidomo && (
{accessToSodistorePro && (
<List component="div">
<ListItem component="div">
<Button
disableRipple
component={RouterLink}
onClick={closeSidebar}
to="/salidomo_installations"
to="/sodistorepro_installations"
startIcon={<BrightnessLowTwoToneIcon />}
>
<Box sx={{ marginTop: '3px' }}>
<FormattedMessage
id="salidomo"
defaultMessage="Salidomo"
id="sodistorepro"
defaultMessage="Sodistore Pro"
/>
</Box>
</Button>
@ -265,20 +249,59 @@ function SidebarMenu() {
</List>
)}
{accessToSodiohome && (
{accessToSodistore && (
<List component="div">
<ListItem component="div">
<Button
disableRipple
component={RouterLink}
onClick={closeSidebar}
to="/sodiohome_installations"
to="/sodistore_installations"
startIcon={<BrightnessLowTwoToneIcon />}
>
<Box sx={{ marginTop: '3px' }}>
<FormattedMessage
id="sodiohome"
defaultMessage="Sodistore Home"
id="sodistore"
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>
</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 { Outlet, useLocation } from 'react-router-dom';
import Joyride, { CallBackProps, STATUS, Step } from 'react-joyride';
import { useIntl, IntlShape } from 'react-intl';
import Joyride, { CallBackProps, EVENTS, STATUS, Step } from 'react-joyride';
import { useIntl } from 'react-intl';
import { useTour } from 'src/contexts/TourContext';
import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes';
import {
buildSodiohomeCustomerTourSteps, buildSodiohomePartnerTourSteps, buildSodiohomeAdminTourSteps,
buildSalimaxCustomerTourSteps, buildSalimaxPartnerTourSteps, buildSalimaxAdminTourSteps,
buildSodistoregridCustomerTourSteps, buildSodistoregridPartnerTourSteps, buildSodistoregridAdminTourSteps,
buildSalidomoCustomerTourSteps, buildSalidomoPartnerTourSteps, buildSalidomoAdminTourSteps
} from 'src/config/tourSteps';
import { buildDynamicTourSteps } from 'src/config/tourSteps';
import Sidebar from './Sidebar';
import Header from './Header';
@ -22,38 +15,11 @@ interface SidebarLayoutProps {
onSelectLanguage: (item: string) => void;
}
function getTourSteps(pathname: string, userType: UserType, intl: IntlShape, isInsideInstallation: boolean): Step[] {
const role = userType === UserType.admin ? 'admin'
: userType === UserType.partner ? 'partner'
: 'customer';
if (pathname.includes('/sodiohome_installations')) {
if (role === 'admin') return buildSodiohomeAdminTourSteps(intl, isInsideInstallation);
if (role === 'partner') return buildSodiohomePartnerTourSteps(intl, isInsideInstallation);
return buildSodiohomeCustomerTourSteps(intl, isInsideInstallation);
}
if (pathname.includes('/salidomo_installations')) {
if (role === 'admin') return buildSalidomoAdminTourSteps(intl, isInsideInstallation);
if (role === 'partner') return buildSalidomoPartnerTourSteps(intl, isInsideInstallation);
return buildSalidomoCustomerTourSteps(intl, isInsideInstallation);
}
if (pathname.includes('/sodistoregrid_installations')) {
if (role === 'admin') return buildSodistoregridAdminTourSteps(intl, isInsideInstallation);
if (role === 'partner') return buildSodistoregridPartnerTourSteps(intl, isInsideInstallation);
return buildSodistoregridCustomerTourSteps(intl, isInsideInstallation);
}
// Salimax (/installations/) and Sodistore Max (/sodistore_installations/)
if (role === 'admin') return buildSalimaxAdminTourSteps(intl, isInsideInstallation);
if (role === 'partner') return buildSalimaxPartnerTourSteps(intl, isInsideInstallation);
return buildSalimaxCustomerTourSteps(intl, isInsideInstallation);
}
const SidebarLayout = (props: SidebarLayoutProps) => {
const theme = useTheme();
const intl = useIntl();
const { runTour, stopTour } = useTour();
const location = useLocation();
const { currentUser } = useContext(UserContext);
const [tourSteps, setTourSteps] = useState<Step[]>([]);
const [tourReady, setTourReady] = useState(false);
@ -64,23 +30,22 @@ const SidebarLayout = (props: SidebarLayoutProps) => {
}
// Delay to let child components render their tour target elements
const timer = setTimeout(() => {
const userType = currentUser?.userType ?? UserType.client;
const isInsideInstallation = location.pathname.includes('/installation/');
const steps = getTourSteps(location.pathname, userType, intl, isInsideInstallation);
const filtered = steps.filter((step) => {
if (typeof step.target === 'string') {
return document.querySelector(step.target) !== null;
}
return true;
});
setTourSteps(filtered);
const steps = buildDynamicTourSteps(intl, isInsideInstallation);
setTourSteps(steps);
setTourReady(true);
}, 300);
}, 500);
return () => clearTimeout(timer);
}, [runTour, location.pathname, currentUser?.userType, intl]);
}, [runTour, location.pathname, intl]);
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) {
stopTour();
}