diff --git a/csharp/App/Backend/Backend.sln b/csharp/App/Backend/Backend.sln new file mode 100644 index 000000000..c72530762 --- /dev/null +++ b/csharp/App/Backend/Backend.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Backend", "Backend.csproj", "{161624D7-33B9-48B8-BA05-303DCFAEB03E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {161624D7-33B9-48B8-BA05-303DCFAEB03E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {161624D7-33B9-48B8-BA05-303DCFAEB03E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {161624D7-33B9-48B8-BA05-303DCFAEB03E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {161624D7-33B9-48B8-BA05-303DCFAEB03E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A377D3C5-E56D-4A16-AC4B-F3B0BB4CFCCE} + EndGlobalSection +EndGlobal diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 8afccf35e..b4dabe8cc 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -100,6 +100,24 @@ public class Controller : ControllerBase .ToList(); } + [HttpGet(nameof(GetHistoryForInstallation))] + public ActionResult> GetHistoryForInstallation(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(id); + + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + return Db.UserActions + .Where(action =>action.InstallationId == id) + .OrderByDescending(action => action.Timestamp) + .ToList(); + } + [HttpGet(nameof(GetAllWarningsForInstallation))] public ActionResult> GetAllWarningsForInstallation(Int64 id, Token authToken) { @@ -552,12 +570,19 @@ public class Controller : ControllerBase { var session = Db.GetSession(authToken); //Console.WriteLine(config.GridSetPoint); - - //var installationToUpdate = Db.GetInstallationById(installationId); - return await session.SendInstallationConfig(installationId, config) - ? Ok() - : Unauthorized(); + // Send configuration changes + var success = await session.SendInstallationConfig(installationId, config); + + // Record configuration change + if (success) + { + var actionSuccess = await session.RecordUserAction(installationId, config); + return actionSuccess?Ok():Unauthorized(); + } + + return Unauthorized(); + } [HttpPut(nameof(MoveFolder))] diff --git a/csharp/App/Backend/DataTypes/Configuration.cs b/csharp/App/Backend/DataTypes/Configuration.cs index 7bd6a830c..76ac6edbe 100644 --- a/csharp/App/Backend/DataTypes/Configuration.cs +++ b/csharp/App/Backend/DataTypes/Configuration.cs @@ -6,6 +6,10 @@ public class Configuration public Double GridSetPoint { get; set; } public CalibrationChargeType CalibrationChargeState { get; set; } public DateTime CalibrationChargeDate { get; set; } + public String GetConfigurationString() + { + return $"MinimumSoC: {MinimumSoC}, GridSetPoint: {GridSetPoint}, CalibrationChargeState: {CalibrationChargeState}, CalibrationChargeDate: {CalibrationChargeDate}"; + } } public enum CalibrationChargeType diff --git a/csharp/App/Backend/DataTypes/Methods/Session.cs b/csharp/App/Backend/DataTypes/Methods/Session.cs index f8ff5d571..7fe7a23fa 100644 --- a/csharp/App/Backend/DataTypes/Methods/Session.cs +++ b/csharp/App/Backend/DataTypes/Methods/Session.cs @@ -75,14 +75,14 @@ public static class SessionMethods await Task.Run(() => { - Process process = new Process(); + var process = new Process(); process.StartInfo.FileName = "/bin/bash"; process.StartInfo.Arguments = $"{scriptPath} {vpnIp} {batteryNode} {version}"; process.StartInfo.UseShellExecute = false; process.StartInfo.RedirectStandardOutput = true; process.Start(); - string output = process.StandardOutput.ReadToEnd(); + var output = process.StandardOutput.ReadToEnd(); process.WaitForExit(); Console.WriteLine(output); }); @@ -101,6 +101,28 @@ public static class SessionMethods && user.HasAccessTo(installation) && await installation.SendConfig(configuration); } + + public static async Task RecordUserAction(this Session? session, Int64 installationId, Configuration newConfiguration) + { + var user = session?.User; + var timestamp = DateTime.Now; + + if (user is null || user.UserType == 0) + return false; + + // Create a new UserAction object + var action = new UserAction + { + UserName = user.Name, + InstallationId = installationId, + Timestamp = timestamp, + Description = newConfiguration.GetConfigurationString() + }; + + // Save the configuration change to the database + Db.HandleAction(action); + return true; + } public static Boolean Delete(this Session? session, Folder? folder) { @@ -121,7 +143,7 @@ public static class SessionMethods //Salimax installation - if (installation.Product==0) + if (installation.Product == 0) { return user is not null && user.UserType != 0 @@ -134,7 +156,7 @@ public static class SessionMethods } - if (installation.Product==1) + if (installation.Product == 1) { return user is not null && user.UserType != 0 @@ -153,7 +175,7 @@ public static class SessionMethods var original = Db.GetInstallationById(installation?.Id); //Salimax installation - if (installation.Product==0) + if (installation.Product == 0) { return user is not null diff --git a/csharp/App/Backend/DataTypes/UserAction.cs b/csharp/App/Backend/DataTypes/UserAction.cs new file mode 100644 index 000000000..9a20cff26 --- /dev/null +++ b/csharp/App/Backend/DataTypes/UserAction.cs @@ -0,0 +1,18 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public class UserAction +{ + [PrimaryKey, AutoIncrement] + public Int64 Id { get; set; } // Primary key for the table, auto-incremented + + [Indexed] + public String UserName { get; set; } = null!;// User Name who made the configuration change + + public Int64 InstallationId { get; set; } // Installation ID where the configuration change is made + + public DateTime Timestamp { get; set; } // Timestamp of the configuration change + + public String Description { get; set; } = null!;// Serialized string representing the new configuration +} \ No newline at end of file diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index d1b50e107..b25cc089f 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -62,6 +62,37 @@ public static partial class Db return Insert(o2i); } + public static Boolean Create(UserAction action) + { + return Insert(action); + } + + public static void HandleAction(UserAction newAction) + { + //Find the total number of actions for this installation + var totalActions = UserActions.Count(action => action.InstallationId == newAction.InstallationId); + + //If there are 100 actions, remove the one with the oldest timestamp + if (totalActions == 100) + { + var oldestAction = + UserActions.Where(action => action.InstallationId == newAction.InstallationId) + .OrderBy(action => action.Timestamp) + .FirstOrDefault(); + + //Remove the old action + Delete(oldestAction); + + //Add the new action + Create(newAction); + } + else + { + Console.WriteLine("---------------Added the new Error to the database-----------------"); + Create(newAction); + } + } + public static void HandleError(Error newError,int installationId) { //Find the total number of errors for this installation diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 130c10649..9ec5ecc6d 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -36,6 +36,7 @@ public static partial class Db fileConnection.CreateTable(); fileConnection.CreateTable(); fileConnection.CreateTable(); + fileConnection.CreateTable(); return fileConnection; //return CopyDbToMemory(fileConnection); @@ -55,6 +56,7 @@ public static partial class Db memoryConnection.CreateTable(); memoryConnection.CreateTable(); memoryConnection.CreateTable(); + fileConnection.CreateTable(); //Copy all the existing tables from the disk to main memory fileConnection.Table().ForEach(memoryConnection.Insert); @@ -66,6 +68,7 @@ public static partial class Db fileConnection.Table().ForEach(memoryConnection.Insert); fileConnection.Table().ForEach(memoryConnection.Insert); fileConnection.Table().ForEach(memoryConnection.Insert); + fileConnection.Table().ForEach(memoryConnection.Insert); return memoryConnection; } @@ -85,6 +88,7 @@ public static partial class Db public static TableQuery OrderNumber2Installation => Connection.Table(); public static TableQuery Errors => Connection.Table(); public static TableQuery Warnings => Connection.Table(); + public static TableQuery UserActions => Connection.Table(); public static void Init() { @@ -106,6 +110,7 @@ public static partial class Db Connection.CreateTable(); Connection.CreateTable(); Connection.CreateTable(); + Connection.CreateTable(); }); //UpdateKeys(); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index f50fa3761..d9ee41aab 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -1,6 +1,7 @@ using InnovEnergy.App.Backend.DataTypes; using InnovEnergy.App.Backend.DataTypes.Methods; using InnovEnergy.App.Backend.Relations; +using Microsoft.AspNetCore.Authentication.OAuth.Claims; namespace InnovEnergy.App.Backend.Database; @@ -49,6 +50,20 @@ public static partial class Db } } + public static Boolean Delete(UserAction actionToDelete) + { + var deleteSuccess = RunTransaction(DeleteAction); + if (deleteSuccess) + BackupDatabase(); + return deleteSuccess; + + + Boolean DeleteAction() + { + return UserActions.Delete(action => action.Id == actionToDelete.Id) >0; + } + } + public static Boolean Delete(Warning warningToDelete) { var deleteSuccess = RunTransaction(DeleteWarning); diff --git a/csharp/App/SaliMax/src/AggregationService/Aggregator.cs b/csharp/App/SaliMax/src/AggregationService/Aggregator.cs index bd37c99d8..8a6a0ea70 100644 --- a/csharp/App/SaliMax/src/AggregationService/Aggregator.cs +++ b/csharp/App/SaliMax/src/AggregationService/Aggregator.cs @@ -227,8 +227,8 @@ public static class Aggregator Console.WriteLine($"Max SOC: {aggregatedData.MaxSoc}"); Console.WriteLine($"Min SOC: {aggregatedData.MinSoc}"); - Console.WriteLine($"ChargingBatteryPower: {aggregatedData.DischargingBatteryPower}"); - Console.WriteLine($"DischargingBatteryBattery: {aggregatedData.ChargingBatteryPower}"); + Console.WriteLine($"DischargingBatteryBattery: {aggregatedData.DischargingBatteryPower}"); + Console.WriteLine($"ChargingBatteryPower: {aggregatedData.ChargingBatteryPower}"); Console.WriteLine($"SumGridExportPower: {aggregatedData.GridExportPower}"); Console.WriteLine($"SumGridImportPower: {aggregatedData.GridImportPower}"); diff --git a/csharp/App/SaliMax/src/Program.cs b/csharp/App/SaliMax/src/Program.cs index ee0a76355..98af9657c 100644 --- a/csharp/App/SaliMax/src/Program.cs +++ b/csharp/App/SaliMax/src/Program.cs @@ -319,7 +319,7 @@ internal static class Program if (s3Bucket != null) RabbitMqManager.InformMiddleware(currentSalimaxState); } - else if (_subscribedToQueue && _heartBitInterval >= 15) + else if (_subscribedToQueue && _heartBitInterval >= 30) { //Send a heartbit to the backend Console.WriteLine("----------------------------------------Sending Heartbit----------------------------------------"); @@ -676,11 +676,13 @@ internal static class Program var s3Path = timeStamp.ToUnixTime() + ".csv"; var request = s3Config.CreatePutRequest(s3Path); - // This is temporary for Wittman, but now it's for all Instalattion + // This is temporary for Wittman, but now it's for all Installation await File.WriteAllTextAsync("/var/www/html/status.csv", csv.SplitLines().Where(l => !l.Contains("Secret")).JoinLines()); - - // Compress CSV data to a byte array + //Use this for no compression + //var response = await request.PutAsync(new StringContent(csv)); + + //Compress CSV data to a byte array byte[] compressedBytes; using (var memoryStream = new MemoryStream()) { @@ -694,16 +696,16 @@ internal static class Program writer.Write(csv); } } - + compressedBytes = memoryStream.ToArray(); } - + // Encode the compressed byte array as a Base64 string string base64String = Convert.ToBase64String(compressedBytes); - + // Create StringContent from Base64 string var stringContent = new StringContent(base64String, Encoding.UTF8, "application/base64"); - + // Upload the compressed data (ZIP archive) to S3 var response = await request.PutAsync(stringContent); diff --git a/csharp/App/SaliMax/src/S3Config.cs b/csharp/App/SaliMax/src/S3Config.cs index 741a5c565..48ff5c218 100644 --- a/csharp/App/SaliMax/src/S3Config.cs +++ b/csharp/App/SaliMax/src/S3Config.cs @@ -62,15 +62,9 @@ public record S3Config String contentType = "application/base64", String md5Hash = "") { - // StringToSign = HTTP-Verb + "\n" + - // Content-MD5 + "\n" + - // Content-Type + "\n" + - // Date + "\n" + - // CanonicalizedAmzHeaders + - // CanonicalizedResource; - - + contentType = "application/base64; charset=utf-8"; + //contentType = "text/plain; charset=utf-8"; var payload = $"{method}\n{md5Hash}\n{contentType}\n{date}\n/{bucket.Trim('/')}/{s3Path.Trim('/')}"; using var hmacSha1 = new HMACSHA1(UTF8.GetBytes(s3Secret)); diff --git a/csharp/Lib/Devices/AMPT/AmptDevices.cs b/csharp/Lib/Devices/AMPT/AmptDevices.cs index 1674baffe..003e09b20 100644 --- a/csharp/Lib/Devices/AMPT/AmptDevices.cs +++ b/csharp/Lib/Devices/AMPT/AmptDevices.cs @@ -77,8 +77,8 @@ public class AmptDevices Current = busCurrent }; - // flatten the 2 strings of each SO into one array - var strings = soStati.SelectMany(GetStrings).ToArray(nStrings); + // flatten the output strings of each SO into one array + var strings = soStati.SelectMany(GetDc).ToArray(nStrings); return new AmptStatus { @@ -107,6 +107,16 @@ public class AmptDevices }; } + private static IEnumerable GetDc(StringOptimizerRegisters r) + { + // hardcoded: every SO has 2 strings (produced like this by AMPT) + + yield return new() + { + Voltage = r.Voltage, + Current = r.Current, + }; + } private static IEnumerable> StringOptimizers(ModbusClient modbusClient) { diff --git a/typescript/frontend-marios2/src/Resources/routes.json b/typescript/frontend-marios2/src/Resources/routes.json index f86097f4b..88f219a2a 100644 --- a/typescript/frontend-marios2/src/Resources/routes.json +++ b/typescript/frontend-marios2/src/Resources/routes.json @@ -11,10 +11,12 @@ "overview": "overview", "manage": "manage", "batteryview": "batteryview", + "pvview": "pvview", "log": "log", "live": "live", "information": "information", "configuration": "configuration", + "history": "history", "mainstats": "mainstats", "detailed_view": "detailed_view/" } diff --git a/typescript/frontend-marios2/src/content/dashboards/BatteryView/MainStats.tsx b/typescript/frontend-marios2/src/content/dashboards/BatteryView/MainStats.tsx index 7861a57c8..63ca5b839 100644 --- a/typescript/frontend-marios2/src/content/dashboards/BatteryView/MainStats.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/BatteryView/MainStats.tsx @@ -246,8 +246,8 @@ function MainStats(props: MainStatsProps) { chartOverview: BatteryOverviewInterface; }> = transformInputToBatteryViewData( props.s3Credentials, - UnixTime.fromTicks(startX), - UnixTime.fromTicks(endX) + UnixTime.fromTicks(startX).earlier(TimeSpan.fromHours(2)), + UnixTime.fromTicks(endX).earlier(TimeSpan.fromHours(2)) ); resultPromise diff --git a/typescript/frontend-marios2/src/content/dashboards/Configuration/Configuration.tsx b/typescript/frontend-marios2/src/content/dashboards/Configuration/Configuration.tsx index 66b682adf..dbb832c1d 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Configuration/Configuration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Configuration/Configuration.tsx @@ -15,7 +15,7 @@ import { Typography, useTheme } from '@mui/material'; -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import Button from '@mui/material/Button'; import { Close as CloseIcon } from '@mui/icons-material'; @@ -29,6 +29,7 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import dayjs from 'dayjs'; import axiosConfig from '../../../Resources/axiosConfig'; import utc from 'dayjs/plugin/utc'; +import { UserContext } from '../../../contexts/userContext'; interface ConfigurationProps { values: TopologyValues; @@ -82,6 +83,8 @@ function Configuration(props: ConfigurationProps) { const [updated, setUpdated] = useState(false); const [dateSelectionError, setDateSelectionError] = useState(''); const [isErrorDateModalOpen, setErrorDateModalOpen] = useState(false); + const context = useContext(UserContext); + const { currentUser, setUser } = context; const [formValues, setFormValues] = useState({ minimumSoC: props.values.minimumSoC[0].value, @@ -132,7 +135,6 @@ function Configuration(props: ConfigurationProps) { .add(localOffset, 'minute') .toDate() }; - // console.log('will send ', dayjs(formValues.calibrationChargeDate)); setLoading(true); diff --git a/typescript/frontend-marios2/src/content/dashboards/History/History.tsx b/typescript/frontend-marios2/src/content/dashboards/History/History.tsx new file mode 100644 index 000000000..25de1adce --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/History/History.tsx @@ -0,0 +1,308 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { + Alert, + Card, + Container, + Divider, + Grid, + IconButton, + useTheme +} from '@mui/material'; +import Typography from '@mui/material/Typography'; +import { FormattedMessage } from 'react-intl'; +import axiosConfig from '../../../Resources/axiosConfig'; +import { AxiosError, AxiosResponse } from 'axios/index'; +import routes from '../../../Resources/routes.json'; +import { useNavigate } from 'react-router-dom'; +import { TokenContext } from '../../../contexts/tokenContext'; +import { Action } from '../../../interfaces/S3Types'; + +interface HistoryProps { + errorLoadingS3Data: boolean; + id: number; +} + +function HistoryOfActions(props: HistoryProps) { + const theme = useTheme(); + const searchParams = new URLSearchParams(location.search); + + const [history, setHistory] = useState([]); + const navigate = useNavigate(); + const tokencontext = useContext(TokenContext); + const { removeToken } = tokencontext; + + useEffect(() => { + axiosConfig + .get(`/GetHistoryForInstallation?id=${props.id}`) + .then((res: AxiosResponse) => { + setHistory(res.data); + }) + .catch((err: AxiosError) => { + if (err.response && err.response.status == 401) { + removeToken(); + navigate(routes.login); + } + }); + }, []); + + return ( + + + + {history.length > 0 && ( + + +
+
+
+ + + +
+ +
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ {history.map((action, index) => { + // Parse the timestamp string to a Date object + const date = new Date(action.timestamp); + + // Extract the date part (e.g., "2023-05-31") + const datePart = date.toLocaleDateString(); + + // Extract the time part (e.g., "12:34:56") + const timePart = date.toLocaleTimeString(); + + return ( + + +
+
+ + {action.userName} + +
+ +
+ + {datePart} + +
+
+ + {timePart} + +
+ +
+ + {action.description} + +
+
+
+ ); + })} +
+
+
+ )} + + {!props.errorLoadingS3Data && history.length == 0 && ( + + + + + )} +
+
+ + + {props.errorLoadingS3Data && ( + + + + + )} + +
+ ); +} + +export default HistoryOfActions; diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx index e812e91fa..23f25107a 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx @@ -23,6 +23,8 @@ import routes from '../../../Resources/routes.json'; import Information from '../Information/Information'; import BatteryView from '../BatteryView/BatteryView'; import { UserType } from '../../../interfaces/UserTypes'; +import HistoryOfActions from '../History/History'; +import PvView from '../PvView/PvView'; interface singleInstallationProps { current_installation?: I_Installation; @@ -123,6 +125,7 @@ function Installation(props: singleInstallationProps) { useEffect(() => { if ( currentTab == 'live' || + currentTab == 'pvview' || currentTab == 'configuration' || location.includes('batteryview') ) { @@ -130,7 +133,8 @@ function Installation(props: singleInstallationProps) { if ( currentTab == 'live' || - (location.includes('batteryview') && !location.includes('mainstats')) + (location.includes('batteryview') && !location.includes('mainstats')) || + currentTab == 'pvview' ) { fetchDataPeriodically(); interval = setInterval(fetchDataPeriodically, 2000); @@ -143,6 +147,7 @@ function Installation(props: singleInstallationProps) { return () => { if ( currentTab == 'live' || + currentTab == 'pvview' || (location.includes('batteryview') && !location.includes('mainstats')) ) { clearInterval(interval); @@ -309,6 +314,14 @@ function Installation(props: singleInstallationProps) { > } > + + + } + > + } @@ -342,6 +355,18 @@ function Installation(props: singleInstallationProps) { } /> )} + {currentUser.userType == UserType.admin && ( + + } + /> + )} + {currentUser.userType == UserType.admin && ( (undefined); @@ -137,6 +139,19 @@ function InstallationTabs() { defaultMessage="Configuration" /> ) + }, + { + value: 'history', + label: ( + + ) + }, + { + value: 'pvview', + label: } ] : currentUser.userType == UserType.partner @@ -158,6 +173,10 @@ function InstallationTabs() { /> ) }, + { + value: 'pvview', + label: + }, { value: 'information', @@ -217,6 +236,10 @@ function InstallationTabs() { /> ) }, + { + value: 'pvview', + label: + }, { value: 'manage', label: ( @@ -248,6 +271,15 @@ function InstallationTabs() { defaultMessage="Configuration" /> ) + }, + { + value: 'history', + label: ( + + ) } ] : currentUser.userType == UserType.partner @@ -280,6 +312,10 @@ function InstallationTabs() { /> ) }, + { + value: 'pvview', + label: + }, { value: 'information', diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx index 79237a1e5..9ecb56639 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx @@ -35,6 +35,13 @@ export type ConfigurationValues = { calibrationChargeDate: Date | null; }; +export interface Pv { + PvId: number; + Power: I_BoxDataValue; + Voltage: I_BoxDataValue; + Current: I_BoxDataValue; +} + export interface Battery { BatteryId: number; FwVersion: I_BoxDataValue; @@ -79,6 +86,8 @@ export interface Battery { MaxDischargePower: I_BoxDataValue; } +const PvKeys = ['PvId', 'Power', 'Voltage', 'Current']; + const BatteryKeys = [ 'BatteryId', 'FwVersion', @@ -163,11 +172,24 @@ export type TopologyValues = { additionalCalibrationChargeDate: I_BoxDataValue[]; batteryView: Battery[]; + + pvView: Pv[]; }; type TopologyPaths = { [key in keyof TopologyValues]: string[] }; const batteryIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const pvIds = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30 +]; + +const PvPaths = [ + '/PvOnDc/Strings/%id%/Power', + '/PvOnDc/Strings/%id%/Voltage', + '/PvOnDc/Strings/%id%/Current' +]; + const batteryPaths = [ '/Battery/Devices/%id%/FwVersion', '/Battery/Devices/%id%/Dc/Power', @@ -290,6 +312,10 @@ export const topologyPaths: TopologyPaths = { batteryPaths.map((path) => path.replace('%id%', id.toString())) ), + pvView: pvIds.flatMap((id) => + PvPaths.map((path) => path.replace('%id%', id.toString())) + ), + minimumSoC: ['/Config/MinSoc'], installedDcDcPower: ['/DcDc/SystemControl/NumberOfConnectedSlaves'], gridSetPoint: ['/Config/GridSetPoint'], @@ -317,15 +343,47 @@ export const extractValues = ( timeSeriesData: DataPoint ): TopologyValues | null => { const extractedValues: TopologyValues = {} as TopologyValues; - - // console.log('timeSeriesData=', timeSeriesData); - for (const topologyKey of Object.keys(topologyPaths)) { //Each topologykey may have more than one paths (for example inverter) const paths = topologyPaths[topologyKey]; let topologyValues: { unit: string; value: string | number }[] = []; - if (topologyKey === 'batteryView') { + if (topologyKey === 'pvView') { + extractedValues[topologyKey] = []; + let pv_index = 0; + let pathIndex = 0; + + while (pathIndex < paths.length) { + let pv = {}; + let existingKeys = 0; + + //We prepare a pv object for each node. We extract the number of nodes from the '/PvOnDc/NbrOfStrings' path. + //PvKeys[0] is the pv id. + pv[PvKeys[0]] = pv_index; + //Then, search all the remaining battery keys + for (let i = 1; i < PvKeys.length; i++) { + const path = paths[pathIndex]; + if (timeSeriesData.value.hasOwnProperty(path)) { + existingKeys++; + + pv[PvKeys[i]] = { + unit: timeSeriesData.value[path].unit.includes('~') + ? timeSeriesData.value[path].unit.replace('~', '') + : timeSeriesData.value[path].unit, + value: + typeof timeSeriesData.value[path].value === 'string' + ? timeSeriesData.value[path].value + : Number(timeSeriesData.value[path].value).toFixed(1) + }; + } + pathIndex++; + } + pv_index++; + if (existingKeys > 0) { + extractedValues[topologyKey].push(pv as Pv); + } + } + } else if (topologyKey === 'batteryView') { extractedValues[topologyKey] = []; const node_ids_from_csv = timeSeriesData.value[ '/Config/Devices/BatteryNodes' diff --git a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx index 8881c7f1e..1bd5daa80 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx @@ -119,8 +119,8 @@ function Overview(props: OverviewProps) { chartOverview: overviewInterface; }> = transformInputToDailyData( props.s3Credentials, - UnixTime.fromTicks(startX), - UnixTime.fromTicks(endX) + UnixTime.fromTicks(startX).earlier(TimeSpan.fromHours(2)), + UnixTime.fromTicks(endX).earlier(TimeSpan.fromHours(2)) ); let isComponentMounted = true; diff --git a/typescript/frontend-marios2/src/content/dashboards/PvView/PvView.tsx b/typescript/frontend-marios2/src/content/dashboards/PvView/PvView.tsx new file mode 100644 index 000000000..548fb36fc --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/PvView/PvView.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useState } from 'react'; +import { + Container, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography +} from '@mui/material'; +import { TopologyValues } from '../Log/graph.util'; +import { useLocation, useNavigate } from 'react-router-dom'; +import routes from '../../../Resources/routes.json'; +import CircularProgress from '@mui/material/CircularProgress'; + +interface PvViewProps { + values: TopologyValues; + connected: boolean; +} + +function PvView(props: PvViewProps) { + if (props.values === null && props.connected == true) { + return null; + } + const currentLocation = useLocation(); + const navigate = useNavigate(); + const sortedPvView = + props.values != null + ? [...props.values.pvView].sort((a, b) => a.PvId - b.PvId) + : []; + + const [loading, setLoading] = useState(sortedPvView.length == 0); + + const handleMainStatsButton = () => { + navigate(routes.mainstats); + }; + + const findBatteryData = (batteryId: number) => { + for (let i = 0; i < props.values.batteryView.length; i++) { + if (props.values.batteryView[i].BatteryId == batteryId) { + return props.values.batteryView[i]; + } + } + }; + + useEffect(() => { + if (sortedPvView.length == 0) { + setLoading(true); + } else { + setLoading(false); + } + }, [sortedPvView]); + + return ( + <> + {!props.connected && ( + + + + Unable to communicate with the installation + + + Please wait or refresh the page + + + )} + {loading && props.connected && ( + + + + Battery service is not available at the moment + + + Please wait or refresh the page + + + )} + + {!loading && props.connected && ( + + + + + + Pv + Power + Voltage + Current + + + + {sortedPvView.map((pv) => ( + + + {'String ' + pv.PvId} + + + + {pv.Power.value + ' ' + pv.Power.unit} + + + {pv.Voltage.value + ' ' + pv.Voltage.unit} + + + {pv.Current.value + ' ' + pv.Current.unit} + + + ))} + +
+
+
+ )} + + ); +} + +export default PvView; diff --git a/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx index b673f0d31..6e11a2a10 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx @@ -58,6 +58,28 @@ function Installation(props: singleInstallationProps) { const s3Credentials = { s3Bucket, ...S3data }; + const fetchDataOnlyOneTime = async () => { + var timeperiodToSearch = 70; + + for (var i = timeperiodToSearch; i > 0; i -= 2) { + const now = UnixTime.now().earlier(TimeSpan.fromSeconds(i)); + + const res = await fetchData(now, s3Credentials); + + if (res != FetchResult.notAvailable && res != FetchResult.tryLater) { + setConnected(true); + setFailedToCommunicateWithInstallation(0); + setValues( + extractValues({ + time: now, + value: res + }) + ); + return now; + } + } + }; + const fetchDataPeriodically = async () => { const now = UnixTime.now().earlier(TimeSpan.fromSeconds(20)); @@ -76,7 +98,7 @@ function Installation(props: singleInstallationProps) { return true; } else { setFailedToCommunicateWithInstallation((prevCount) => { - if (prevCount + 1 >= 3) { + if (prevCount + 1 >= 20) { setConnected(false); } return prevCount + 1; @@ -87,19 +109,6 @@ function Installation(props: singleInstallationProps) { } }; - const fetchDataOnlyOneTime = async () => { - let success = false; - const max_retransmissions = 3; - - for (let i = 0; i < max_retransmissions; i++) { - success = await fetchDataPeriodically(); - await new Promise((resolve) => setTimeout(resolve, 1000)); - if (success) { - break; - } - } - }; - useEffect(() => { let path = location.split('/'); @@ -118,7 +127,7 @@ function Installation(props: singleInstallationProps) { currentTab == 'live' || (location.includes('batteryview') && !location.includes('mainstats')) ) { - fetchDataPeriodically(); + fetchDataOnlyOneTime(); interval = setInterval(fetchDataPeriodically, 2000); } if (currentTab == 'configuration' || location.includes('mainstats')) { diff --git a/typescript/frontend-marios2/src/content/dashboards/Topology/Topology.tsx b/typescript/frontend-marios2/src/content/dashboards/Topology/Topology.tsx index 17a73e4b2..e8c4b8260 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Topology/Topology.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Topology/Topology.tsx @@ -222,6 +222,7 @@ function Topology(props: TopologyProps) { }} bottomBox={{ title: 'AC Loads', + data: props.values.islandBusToLoadOnIslandBusConnection, connected: props.values.loadOnIslandBusBox[0].value.toString() != diff --git a/typescript/frontend-marios2/src/interfaces/S3Types.tsx b/typescript/frontend-marios2/src/interfaces/S3Types.tsx index 7309b1cb5..6d7c56db0 100644 --- a/typescript/frontend-marios2/src/interfaces/S3Types.tsx +++ b/typescript/frontend-marios2/src/interfaces/S3Types.tsx @@ -16,3 +16,11 @@ export interface ErrorMessage { deviceCreatedTheMessage: string; seen: boolean; } + +export interface Action { + id: number; + userName: string; + installationId: number; + timestamp: string; + description: String; +}