Merge branch 'main' of 91.92.155.224:Innovenergy/Innovenergy_trunk

This commit is contained in:
Yinyin Liu 2024-06-12 09:47:48 +02:00
commit 6d90b65bf6
28 changed files with 873 additions and 424 deletions

View File

@ -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

View File

@ -100,6 +100,24 @@ public class Controller : ControllerBase
.ToList();
}
[HttpGet(nameof(GetHistoryForInstallation))]
public ActionResult<IEnumerable<UserAction>> 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<IEnumerable<Warning>> GetAllWarningsForInstallation(Int64 id, Token authToken)
{
@ -553,11 +571,18 @@ public class Controller : ControllerBase
var session = Db.GetSession(authToken);
//Console.WriteLine(config.GridSetPoint);
//var installationToUpdate = Db.GetInstallationById(installationId);
// 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();
return await session.SendInstallationConfig(installationId, config)
? Ok()
: Unauthorized();
}
[HttpPut(nameof(MoveFolder))]

View File

@ -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

View File

@ -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);
});
@ -102,6 +102,28 @@ public static class SessionMethods
&& await installation.SendConfig(configuration);
}
public static async Task<Boolean> 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)
{
var user = session?.User;
@ -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

View File

@ -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
}

View File

@ -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

View File

@ -36,6 +36,7 @@ public static partial class Db
fileConnection.CreateTable<OrderNumber2Installation>();
fileConnection.CreateTable<Error>();
fileConnection.CreateTable<Warning>();
fileConnection.CreateTable<UserAction>();
return fileConnection;
//return CopyDbToMemory(fileConnection);
@ -55,6 +56,7 @@ public static partial class Db
memoryConnection.CreateTable<OrderNumber2Installation>();
memoryConnection.CreateTable<Error>();
memoryConnection.CreateTable<Warning>();
fileConnection.CreateTable<UserAction>();
//Copy all the existing tables from the disk to main memory
fileConnection.Table<Session>().ForEach(memoryConnection.Insert);
@ -66,6 +68,7 @@ public static partial class Db
fileConnection.Table<OrderNumber2Installation>().ForEach(memoryConnection.Insert);
fileConnection.Table<Error>().ForEach(memoryConnection.Insert);
fileConnection.Table<Warning>().ForEach(memoryConnection.Insert);
fileConnection.Table<UserAction>().ForEach(memoryConnection.Insert);
return memoryConnection;
}
@ -85,6 +88,7 @@ public static partial class Db
public static TableQuery<OrderNumber2Installation> OrderNumber2Installation => Connection.Table<OrderNumber2Installation>();
public static TableQuery<Error> Errors => Connection.Table<Error>();
public static TableQuery<Warning> Warnings => Connection.Table<Warning>();
public static TableQuery<UserAction> UserActions => Connection.Table<UserAction>();
public static void Init()
{
@ -106,6 +110,7 @@ public static partial class Db
Connection.CreateTable<OrderNumber2Installation>();
Connection.CreateTable<Error>();
Connection.CreateTable<Warning>();
Connection.CreateTable<UserAction>();
});
//UpdateKeys();

View File

@ -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);

View File

@ -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}");

View File

@ -679,34 +679,35 @@ internal static class Program
// This is temporary for Wittman, but now it's for all Instalattion
await File.WriteAllTextAsync("/var/www/html/status.csv", csv.SplitLines().Where(l => !l.Contains("Secret")).JoinLines());
var response = await request.PutAsync(new StringContent(csv));
// Compress CSV data to a byte array
byte[] compressedBytes;
using (var memoryStream = new MemoryStream())
{
//Create a zip directory and put the compressed file inside
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
{
var entry = archive.CreateEntry("data.csv", CompressionLevel.SmallestSize); // Add CSV data to the ZIP archive
using (var entryStream = entry.Open())
using (var writer = new StreamWriter(entryStream))
{
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);
// byte[] compressedBytes;
// using (var memoryStream = new MemoryStream())
// {
// //Create a zip directory and put the compressed file inside
// using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
// {
// var entry = archive.CreateEntry("data.csv", CompressionLevel.SmallestSize); // Add CSV data to the ZIP archive
// using (var entryStream = entry.Open())
// using (var writer = new StreamWriter(entryStream))
// {
// 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);
//
if (response.StatusCode != 200)
{
Console.WriteLine("ERROR: PUT");

View File

@ -70,7 +70,8 @@ public record S3Config
// CanonicalizedResource;
contentType = "application/base64; charset=utf-8";
//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));

View File

@ -77,12 +77,13 @@ 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
{
Dc = dc,
NbrOfStrings = nStringOptimizers,
Strings = strings,
DcWh = dailyOutputEnergy
};
@ -106,6 +107,16 @@ public class AmptDevices
};
}
private static IEnumerable<DcBus> 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<ModbusDevice<StringOptimizerRegisters>> StringOptimizers(ModbusClient modbusClient)
{

View File

@ -5,8 +5,9 @@ namespace InnovEnergy.Lib.Devices.AMPT;
public class AmptStatus : IMppt
{
public required DcBus Dc { get; init; }
public required IReadOnlyList<DcBus> Strings { get; init; }
public required Double DcWh { get; init; } //Daily integrated string output energy in Wh
public required DcBus Dc { get; init; }
public required UInt16 NbrOfStrings { get; init; }
public required IReadOnlyList<DcBus> Strings { get; init; }
public required Double DcWh { get; init; } //Daily integrated string output energy in Wh
}

View File

@ -1,4 +1,4 @@
/*using InnovEnergy.Lib.Protocols.Modbus.Channels;
using InnovEnergy.Lib.Protocols.Modbus.Channels;
using InnovEnergy.Lib.Protocols.Modbus.Clients;
using InnovEnergy.Lib.Protocols.Modbus.Slaves;
using InnovEnergy.Lib.Utils;
@ -49,83 +49,4 @@ public class Iem3KGridMeterDevice: ModbusDevice<Iem3KGridMeterRegisters>
}
}*/
using InnovEnergy.Lib.Protocols.Modbus.Channels;
using InnovEnergy.Lib.Protocols.Modbus.Clients;
using InnovEnergy.Lib.Protocols.Modbus.Slaves;
using InnovEnergy.Lib.Utils;
using System;
namespace InnovEnergy.Lib.Devices.IEM3kGridMeter
{
public class Iem3KGridMeterDevice : ModbusDevice<Iem3KGridMeterRegisters>
{
private readonly string _hostname;
private readonly ushort _port;
private readonly byte _slaveId;
public Iem3KGridMeterDevice(string hostname, ushort port = 502, byte slaveId = 1)
: this(new TcpChannel(hostname, port), slaveId)
{
_hostname = hostname ?? throw new ArgumentNullException(nameof(hostname));
_port = port;
_slaveId = slaveId;
}
private Iem3KGridMeterDevice(TcpChannel channel, byte slaveId = 1)
: base(new ModbusTcpClient(channel, slaveId))
{
_hostname = channel.Host;
_port = channel.Port;
_slaveId = slaveId;
Console.WriteLine($"Initializing Iem3KGridMeterDevice with channel: {channel.Host}:{channel.Port}");
}
public Iem3KGridMeterDevice(ModbusClient client)
: base(client)
{
if (client is ModbusTcpClient tcpClient)
{
_hostname = tcpClient.Channel.Host;
_port = tcpClient.Channel.Port;
_slaveId = tcpClient.SlaveId;
}
else
{
throw new ArgumentException("Invalid client type", nameof(client));
}
Console.WriteLine("Initializing Iem3KGridMeterDevice with ModbusClient");
}
public new Iem3KGridMeterRegisters? Read()
{
try
{
Console.WriteLine($"Attempting to read data from {_hostname}:{_port} with slaveId {_slaveId}");
return base.Read();
}
catch (Exception ex)
{
Console.WriteLine($"Failed to read data from {nameof(Iem3KGridMeterDevice)}: {ex.Message}");
return null;
}
}
public new void Write(Iem3KGridMeterRegisters registers)
{
try
{
base.Write(registers);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to write data to {nameof(Iem3KGridMeterDevice)}: {ex.Message}");
}
}
}
}

View File

@ -2,7 +2,7 @@ using System.Net.Sockets;
using InnovEnergy.Lib.Protocols.Modbus.Protocol;
using InnovEnergy.Lib.Utils.Net;
/*namespace InnovEnergy.Lib.Protocols.Modbus.Channels;
namespace InnovEnergy.Lib.Protocols.Modbus.Channels;
public class TcpChannel : ConnectionChannel<TcpClient>
{
@ -82,104 +82,4 @@ public class TcpChannel : ConnectionChannel<TcpClient>
var array = data.ToArray();
tcpClient.GetStream().Write(array, 0, array.Length);
}
}*/
using System;
using System.Net.Sockets;
namespace InnovEnergy.Lib.Protocols.Modbus.Channels
{
public class TcpChannel : Channel, IDisposable
{
public string Host { get; }
public ushort Port { get; }
private const int TimeoutMs = 500; // TODO: parametrize
private Socket? Socket { get; set; }
private byte[] Buffer { get; }
public TcpChannel(string hostname, ushort port)
{
Host = hostname ?? throw new ArgumentNullException(nameof(hostname));
Port = port;
Buffer = new byte[8192]; // Buffer size can be adjusted
}
public override IReadOnlyList<byte> Read(int nBytes)
{
if (Socket == null)
throw new InvalidOperationException("Socket is not connected.");
var buffer = new byte[nBytes];
int bytesRead = 0;
while (bytesRead < nBytes)
{
var read = Socket.Receive(buffer, bytesRead, nBytes - bytesRead, SocketFlags.None);
if (read == 0)
throw new Exception("Socket closed.");
bytesRead += read;
}
return buffer;
}
public override void Write(IReadOnlyList<byte> bytes)
{
if (Socket == null)
throw new InvalidOperationException("Socket is not connected.");
Socket.Send(bytes.ToArray(), SocketFlags.None);
}
public void Connect()
{
if (Socket != null)
return;
Socket = new Socket(SocketType.Stream, ProtocolType.Tcp)
{
Blocking = true,
NoDelay = true,
LingerState = new LingerOption(false, 0),
ReceiveTimeout = TimeoutMs,
SendTimeout = TimeoutMs
};
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeoutMs);
try
{
Socket.ConnectAsync(Host, Port).Wait(TimeoutMs);
}
catch
{
Socket = null;
throw;
}
}
public void Disconnect()
{
if (Socket == null)
return;
try
{
Socket.Close();
}
finally
{
Socket = null;
}
}
public void Dispose()
{
Disconnect();
}
}
}

View File

@ -12,7 +12,7 @@ namespace InnovEnergy.Lib.Protocols.Modbus.Clients;
using UInt16s = IReadOnlyCollection<UInt16>;
using Booleans = IReadOnlyCollection<Boolean>;
/*public class ModbusTcpClient : ModbusClient
public class ModbusTcpClient : ModbusClient
{
public const UInt16 DefaultPort = 502;
private UInt16 _Id;
@ -184,171 +184,4 @@ using Booleans = IReadOnlyCollection<Boolean>;
return new MbData(rxFrm.RegistersRead.RawData, readAddress, Endian);
}
}*/
public class ModbusTcpClient : ModbusClient
{
public const ushort DefaultPort = 502;
private ushort _Id;
public TcpChannel Channel { get; }
public ModbusTcpClient(TcpChannel channel, byte slaveId) : base(channel, slaveId)
{
Channel = channel;
Channel.Connect();
}
private ushort NextId() => unchecked(++_Id);
public override MbData ReadCoils(ushort readAddress, ushort nValues)
{
var id = NextId(); // TODO: check response id
var cmd = new ReadCoilsCommandFrame(SlaveId, readAddress, nValues);
var hdr = new MbapHeader(id, cmd.Data.Count);
var frm = new ModbusTcpFrame(hdr, cmd);
Channel.Write(frm.Data);
var hData = Channel.Read(MbapHeader.Size).ToArray();
var rxHdr = new MbapHeader(hData);
var rxFrm = Channel
.Read(rxHdr.FrameLength)
.ToArray()
.Apply(ReadCoilsResponseFrame.Parse)
.Apply(cmd.VerifyResponse);
return new MbData(rxFrm.Coils.RawData, readAddress, Endian);
}
public override MbData ReadDiscreteInputs(ushort readAddress, ushort nValues)
{
var id = NextId(); // TODO: check response id
var cmd = new ReadDiscreteInputsCommandFrame(SlaveId, readAddress, nValues);
var hdr = new MbapHeader(id, cmd.Data.Count);
var frm = new ModbusTcpFrame(hdr, cmd);
Channel.Write(frm.Data);
var hData = Channel.Read(MbapHeader.Size).ToArray();
var rxHdr = new MbapHeader(hData);
var rxFrm = Channel
.Read(rxHdr.FrameLength)
.ToArray()
.Apply(ReadDiscreteInputsResponseFrame.Parse)
.Apply(cmd.VerifyResponse);
return new MbData(rxFrm.Inputs.RawData, readAddress, Endian);
}
public override MbData ReadInputRegisters(ushort readAddress, ushort nValues)
{
var id = NextId(); // TODO: check response id
var cmd = new ReadInputRegistersCommandFrame(SlaveId, readAddress, nValues);
var hdr = new MbapHeader(id, cmd.Data.Count);
var frm = new ModbusTcpFrame(hdr, cmd);
Channel.Write(frm.Data);
var hData = Channel.Read(MbapHeader.Size).ToArray();
var rxHdr = new MbapHeader(hData);
var rxFrm = Channel
.Read(rxHdr.FrameLength)
.ToArray()
.Apply(ReadInputRegistersResponseFrame.Parse)
.Apply(cmd.VerifyResponse);
return new MbData(rxFrm.RegistersRead.RawData, readAddress, Endian);
}
public override MbData ReadHoldingRegisters(ushort readAddress, ushort nValues)
{
var id = NextId(); // TODO: check response id
var cmd = new ReadHoldingRegistersCommandFrame(SlaveId, readAddress, nValues);
var hdr = new MbapHeader(id, cmd.Data.Count);
var frm = new ModbusTcpFrame(hdr, cmd);
Channel.Write(frm.Data);
var hData = Channel.Read(MbapHeader.Size).ToArray();
var rxHdr = new MbapHeader(hData);
var rxFrm = Channel
.Read(rxHdr.FrameLength)
.ToArray()
.Apply(ReadHoldingRegistersResponseFrame.Parse)
.Apply(cmd.VerifyResponse);
return new MbData(rxFrm.RegistersRead.RawData, readAddress, Endian);
}
public override ushort WriteCoils(ushort writeAddress, Booleans coils)
{
var id = NextId(); // TODO: check response id
var cmd = new WriteCoilsCommandFrame(SlaveId, writeAddress, coils);
var hdr = new MbapHeader(id, cmd.Data.Count);
var frm = new ModbusTcpFrame(hdr, cmd);
Channel.Write(frm.Data);
var hData = Channel.Read(MbapHeader.Size).ToArray();
var rxHdr = new MbapHeader(hData);
var rxFrm = Channel
.Read(rxHdr.FrameLength)
.ToArray()
.Apply(WriteCoilsResponseFrame.Parse)
.Apply(cmd.VerifyResponse);
return rxFrm.NbWritten;
}
public override ushort WriteRegisters(ushort writeAddress, UInt16s values)
{
var id = NextId(); // TODO: check response id
var cmd = new WriteRegistersCommandFrame(SlaveId, writeAddress, values);
var hdr = new MbapHeader(id, cmd.Data.Count);
var frm = new ModbusTcpFrame(hdr, cmd);
Channel.Write(frm.Data);
var hData = Channel.Read(MbapHeader.Size).ToArray();
var rxHdr = new MbapHeader(hData);
var rxFrm = Channel
.Read(rxHdr.FrameLength)
.ToArray()
.Apply(WriteRegistersResponseFrame.Parse)
.Apply(cmd.VerifyResponse);
return rxFrm.NbWritten;
}
public override MbData ReadWriteRegisters(ushort readAddress, ushort nbToRead, ushort writeAddress, UInt16s registersToWrite)
{
var id = NextId(); // TODO: check response id
var cmd = new ReadWriteRegistersCommandFrame(SlaveId, readAddress, nbToRead, writeAddress, registersToWrite);
var hdr = new MbapHeader(id, cmd.Data.Count);
var frm = new ModbusTcpFrame(hdr, cmd);
Channel.Write(frm.Data);
var hData = Enumerable.ToArray(Channel.Read(MbapHeader.Size));
var rxHdr = new MbapHeader(hData);
var fData = Enumerable.ToArray(Channel.Read(rxHdr.FrameLength));
var rxFrm = fData
.Apply(ReadWriteRegistersResponseFrame.Parse)
.Apply(cmd.VerifyResponse);
return new MbData(rxFrm.RegistersRead.RawData, readAddress, Endian);
}
}

View File

@ -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/"
}

View File

@ -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

View File

@ -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<ConfigurationValues>({
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);

View File

@ -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<Action[]>([]);
const navigate = useNavigate();
const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext;
useEffect(() => {
axiosConfig
.get(`/GetHistoryForInstallation?id=${props.id}`)
.then((res: AxiosResponse<Action[]>) => {
setHistory(res.data);
})
.catch((err: AxiosError) => {
if (err.response && err.response.status == 401) {
removeToken();
navigate(routes.login);
}
});
}, []);
return (
<Container maxWidth="xl">
<Grid container>
<Grid item xs={12} md={12}>
{history.length > 0 && (
<Card sx={{ marginTop: '10px' }}>
<Divider />
<div>
<div
style={{
height: '40px',
marginBottom: '10px',
display: 'flex',
alignItems: 'center'
}}
>
<div
style={{
flex: 1,
marginTop: '15px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant="body1"
color="dimgrey"
fontWeight="bold"
fontSize="1rem"
gutterBottom
noWrap
>
<FormattedMessage id="user" defaultMessage="User" />
</Typography>
</div>
<div
style={{
flex: 1,
marginTop: '15px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant="body1"
color="dimgrey"
fontWeight="bold"
fontSize="1rem"
gutterBottom
noWrap
>
<FormattedMessage id="date" defaultMessage="Date" />
</Typography>
</div>
<div
style={{
flex: 1,
marginTop: '15px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant="body1"
color="dimgrey"
fontWeight="bold"
fontSize="1rem"
gutterBottom
noWrap
>
<FormattedMessage id="time" defaultMessage="Time" />
</Typography>
</div>
<div
style={{
flex: 1,
marginTop: '15px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant="body1"
color="dimgrey"
fontWeight="bold"
fontSize="1rem"
gutterBottom
noWrap
>
<FormattedMessage
id="description"
defaultMessage="Description"
/>
</Typography>
</div>
</div>
<Divider />
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
{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 (
<>
<Divider />
<div
key={index}
style={{
height: '40px',
marginBottom: '10px',
display: 'flex',
alignItems: 'center'
}}
>
<div
style={{
flex: 1,
marginTop: '15px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant="body1"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
>
{action.userName}
</Typography>
</div>
<div
style={{
flex: 1,
marginTop: '15px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant="body1"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
>
{datePart}
</Typography>
</div>
<div
style={{
flex: 1,
marginTop: '15px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant="body1"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
>
{timePart}
</Typography>
</div>
<div
style={{
flex: 3,
display: 'flex',
marginTop: '15px',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant="body1"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
>
{action.description}
</Typography>
</div>
</div>
</>
);
})}
</div>
</div>
</Card>
)}
{!props.errorLoadingS3Data && history.length == 0 && (
<Alert
severity="error"
sx={{
display: 'flex',
alignItems: 'center',
marginTop: '20px'
}}
>
<FormattedMessage
id="nohistory"
defaultMessage="There is no history of actions"
/>
<IconButton
color="inherit"
size="small"
sx={{ marginLeft: '4px' }}
></IconButton>
</Alert>
)}
</Grid>
</Grid>
<Grid item xs={12} md={12} style={{ marginBottom: '20px' }}>
{props.errorLoadingS3Data && (
<Alert
severity="error"
sx={{
display: 'flex',
alignItems: 'center',
marginTop: '20px'
}}
>
<FormattedMessage
id="cannotloadloggingdata"
defaultMessage="Cannot load logging data"
/>
<IconButton
color="inherit"
size="small"
sx={{ marginLeft: '4px' }}
></IconButton>
</Alert>
)}
</Grid>
</Container>
);
}
export default HistoryOfActions;

View File

@ -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) {
></BatteryView>
}
></Route>
<Route
path={routes.pvview + '*'}
element={
<PvView values={values} connected={connected}></PvView>
}
></Route>
<Route
path={routes.overview}
element={<Overview s3Credentials={s3Credentials}></Overview>}
@ -342,6 +355,18 @@ function Installation(props: singleInstallationProps) {
}
/>
)}
{currentUser.userType == UserType.admin && (
<Route
path={routes.history}
element={
<HistoryOfActions
errorLoadingS3Data={errorLoadingS3Data}
id={props.current_installation.id}
></HistoryOfActions>
}
/>
)}
{currentUser.userType == UserType.admin && (
<Route
path={routes.manage}

View File

@ -26,7 +26,9 @@ function InstallationTabs() {
'batteryview',
'log',
'information',
'configuration'
'configuration',
'history',
'pvview'
];
const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -137,6 +139,19 @@ function InstallationTabs() {
defaultMessage="Configuration"
/>
)
},
{
value: 'history',
label: (
<FormattedMessage
id="history"
defaultMessage="History Of Actions"
/>
)
},
{
value: 'pvview',
label: <FormattedMessage id="pvview" defaultMessage="Pv View" />
}
]
: currentUser.userType == UserType.partner
@ -158,6 +173,10 @@ function InstallationTabs() {
/>
)
},
{
value: 'pvview',
label: <FormattedMessage id="pvview" defaultMessage="Pv View" />
},
{
value: 'information',
@ -217,6 +236,10 @@ function InstallationTabs() {
/>
)
},
{
value: 'pvview',
label: <FormattedMessage id="pvview" defaultMessage="Pv View" />
},
{
value: 'manage',
label: (
@ -248,6 +271,15 @@ function InstallationTabs() {
defaultMessage="Configuration"
/>
)
},
{
value: 'history',
label: (
<FormattedMessage
id="history"
defaultMessage="History Of Actions"
/>
)
}
]
: currentUser.userType == UserType.partner
@ -280,6 +312,10 @@ function InstallationTabs() {
/>
)
},
{
value: 'pvview',
label: <FormattedMessage id="pvview" defaultMessage="Pv View" />
},
{
value: 'information',

View File

@ -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'

View File

@ -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;

View File

@ -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 && (
<Container
maxWidth="xl"
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '70vh'
}}
>
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
<Typography
variant="body2"
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Unable to communicate with the installation
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
</Typography>
</Container>
)}
{loading && props.connected && (
<Container
maxWidth="xl"
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '70vh'
}}
>
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
<Typography
variant="body2"
style={{ color: 'black', fontWeight: 'bold' }}
mt={2}
>
Battery service is not available at the moment
</Typography>
<Typography variant="body2" style={{ color: 'black' }}>
Please wait or refresh the page
</Typography>
</Container>
)}
{!loading && props.connected && (
<Container maxWidth="xl">
<TableContainer
component={Paper}
sx={{
marginTop: '20px',
marginBottom: '20px'
}}
>
<Table sx={{ minWidth: 250 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell align="center">Pv</TableCell>
<TableCell align="center">Power</TableCell>
<TableCell align="center">Voltage</TableCell>
<TableCell align="center">Current</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedPvView.map((pv) => (
<TableRow
key={pv.PvId}
style={{
height: '10px'
}}
>
<TableCell
component="th"
scope="row"
align="center"
sx={{ width: '10%', fontWeight: 'bold', color: 'black' }}
>
{'String ' + pv.PvId}
</TableCell>
<TableCell
sx={{
width: '10%',
textAlign: 'center',
backgroundColor:
pv.Current.value == 0 ? '#FF033E' : '#32CD32',
color: pv.Power.value === '' ? 'white' : 'inherit'
}}
>
{pv.Power.value + ' ' + pv.Power.unit}
</TableCell>
<TableCell
sx={{
width: '10%',
textAlign: 'center',
backgroundColor:
pv.Current.value == 0 ? '#FF033E' : '#32CD32',
color: pv.Voltage.value === '' ? 'white' : 'inherit'
}}
>
{pv.Voltage.value + ' ' + pv.Voltage.unit}
</TableCell>
<TableCell
sx={{
width: '10%',
textAlign: 'center',
backgroundColor:
pv.Current.value == 0 ? '#FF033E' : '#32CD32',
color: pv.Current.value === '' ? 'white' : 'inherit'
}}
>
{pv.Current.value + ' ' + pv.Current.unit}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Container>
)}
</>
);
}
export default PvView;

View File

@ -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')) {

View File

@ -222,6 +222,7 @@ function Topology(props: TopologyProps) {
}}
bottomBox={{
title: 'AC Loads',
data: props.values.islandBusToLoadOnIslandBusConnection,
connected:
props.values.loadOnIslandBusBox[0].value.toString() !=

View File

@ -16,3 +16,11 @@ export interface ErrorMessage {
deviceCreatedTheMessage: string;
seen: boolean;
}
export interface Action {
id: number;
userName: string;
installationId: number;
timestamp: string;
description: String;
}