Introduce a new Configuration page for
prototype installations . All other installations keep the original one unchanged.
This commit is contained in:
parent
52bc06ccb7
commit
2a258ae0e2
|
|
@ -1908,14 +1908,13 @@ public class Controller : ControllerBase
|
||||||
config.NetworkProvider = installation?.NetworkProvider;
|
config.NetworkProvider = installation?.NetworkProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
string configString = product switch
|
// Serialize what was actually sent — drops null/unset fields so the audit
|
||||||
|
// entry is product-shaped automatically (no per-product formatter to maintain).
|
||||||
|
var configString = System.Text.Json.JsonSerializer.Serialize(config, new System.Text.Json.JsonSerializerOptions
|
||||||
{
|
{
|
||||||
0 => config.GetConfigurationSalimax(), // Salimax
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||||
3 => config.GetConfigurationSodistoreMax(), // SodiStoreMax
|
WriteIndented = false,
|
||||||
2 => config.GetConfigurationSodistoreHome(), // SodiStoreHome
|
});
|
||||||
4 => config.GetConfigurationSodistoreGrid(), // SodistoreGrid
|
|
||||||
_ => config.GetConfigurationString() // fallback
|
|
||||||
};
|
|
||||||
|
|
||||||
Console.WriteLine("CONFIG IS " + configString);
|
Console.WriteLine("CONFIG IS " + configString);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,20 @@ public class Configuration
|
||||||
public CalibrationChargeType? CalibrationDischargeState { get; set; }
|
public CalibrationChargeType? CalibrationDischargeState { get; set; }
|
||||||
public DateTime? CalibrationDischargeDate { get; set; }
|
public DateTime? CalibrationDischargeDate { get; set; }
|
||||||
|
|
||||||
|
// V1 (legacy) flat fields — still used by the original SodistoreHomeConfiguration page
|
||||||
|
// for installations not opted in to V2. WhenWritingNull keeps them out of V2 payloads.
|
||||||
public double? MaximumDischargingCurrent { get; set; }
|
public double? MaximumDischargingCurrent { get; set; }
|
||||||
public double? MaximumChargingCurrent { get; set; }
|
public double? MaximumChargingCurrent { get; set; }
|
||||||
// Nested per-inverter / per-cluster topology + limits (Sinexcel).
|
|
||||||
// Keys: "Inverter1".."InverterN" → { Clusters: { "Cluster1".. }, PvCount }
|
|
||||||
public Dictionary<string, InverterConfig>? Inverters { get; set; }
|
|
||||||
public double? OperatingPriority { get; set; }
|
|
||||||
public int? InverterNumber { get; set; }
|
public int? InverterNumber { get; set; }
|
||||||
public double? BatteriesCount { get; set; }
|
public double? BatteriesCount { get; set; }
|
||||||
public List<int>? BatteriesCountPerInverter { get; set; }
|
public List<int>? BatteriesCountPerInverter { get; set; }
|
||||||
public double? ClusterNumber { get; set; }
|
public double? ClusterNumber { get; set; }
|
||||||
public double? PvNumber { get; set; }
|
public double? PvNumber { get; set; }
|
||||||
|
// V2 — per-inverter Clusters + PvCount, keyed by "Inverter1".."InverterN".
|
||||||
|
// Wire format mirrors the on-disk shape — device merges these into its existing Devices.InverterN entries.
|
||||||
|
// Per-cluster MaxChargingCurrent / MaxDischargingCurrent live inside Devices[InverterN].Clusters[ClusterN].
|
||||||
|
public Dictionary<string, DeviceConfigPartial>? Devices { get; set; }
|
||||||
|
public double? OperatingPriority { get; set; }
|
||||||
public bool ControlPermission { get; set; }
|
public bool ControlPermission { get; set; }
|
||||||
public double? TimeChargeandDischargePower { get; set; }
|
public double? TimeChargeandDischargePower { get; set; }
|
||||||
public DateTime? StartTimeChargeandDischargeDayandTime { get; set; }
|
public DateTime? StartTimeChargeandDischargeDayandTime { get; set; }
|
||||||
|
|
@ -37,42 +40,6 @@ public class Configuration
|
||||||
public string? TimeToBuyFrom { get; set; }
|
public string? TimeToBuyFrom { get; set; }
|
||||||
public string? TimeToBuyTo { get; set; }
|
public string? TimeToBuyTo { get; set; }
|
||||||
|
|
||||||
public String GetConfigurationString()
|
|
||||||
{
|
|
||||||
return $"MinimumSoC: {MinimumSoC}, GridSetPoint: {GridSetPoint}, CalibrationChargeState: {CalibrationChargeState}, CalibrationChargeDate: {CalibrationChargeDate}, " +
|
|
||||||
$"CalibrationDischargeState: {CalibrationDischargeState}, CalibrationDischargeDate: {CalibrationDischargeDate}, " +
|
|
||||||
$"MaximumDischargingCurrent: {MaximumDischargingCurrent}, MaximumChargingCurrent: {MaximumChargingCurrent}, OperatingPriority: {OperatingPriority}, " +
|
|
||||||
$"InverterNumber: {InverterNumber}, BatteriesCount: {BatteriesCount}, BatteriesCountPerInverter: [{(BatteriesCountPerInverter != null ? string.Join(", ", BatteriesCountPerInverter) : "")}], ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+
|
|
||||||
$"SinexcelTimeChargeandDischargePower: {TimeChargeandDischargePower}, SinexcelStartTimeChargeandDischargeDayandTime: {StartTimeChargeandDischargeDayandTime}, SinexcelStopTimeChargeandDischargeDayandTime: {StopTimeChargeandDischargeDayandTime}";
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetConfigurationSalimax()
|
|
||||||
{
|
|
||||||
return
|
|
||||||
$"MinimumSoC: {MinimumSoC}, GridSetPoint: {GridSetPoint}, CalibrationChargeState: {CalibrationChargeState}, CalibrationChargeDate: {CalibrationChargeDate}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetConfigurationSodistoreMax()
|
|
||||||
{
|
|
||||||
return
|
|
||||||
$"MinimumSoC: {MinimumSoC}, GridSetPoint: {GridSetPoint}, CalibrationChargeState: {CalibrationChargeState}, CalibrationChargeDate: {CalibrationChargeDate}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetConfigurationSodistoreHome()
|
|
||||||
{
|
|
||||||
return $"MinimumSoC: {MinimumSoC}, MaximumDischargingCurrent: {MaximumDischargingCurrent}, MaximumChargingCurrent: {MaximumChargingCurrent}, OperatingPriority: {OperatingPriority}, " +
|
|
||||||
$"InverterNumber: {InverterNumber}, BatteriesCount: {BatteriesCount}, BatteriesCountPerInverter: [{(BatteriesCountPerInverter != null ? string.Join(", ", BatteriesCountPerInverter) : "")}], ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+
|
|
||||||
$"SinexcelTimeChargeandDischargePower: {TimeChargeandDischargePower}, SinexcelStartTimeChargeandDischargeDayandTime: {StartTimeChargeandDischargeDayandTime}, SinexcelStopTimeChargeandDischargeDayandTime: {StopTimeChargeandDischargeDayandTime}, " +
|
|
||||||
$"DynamicPricingMode: {DynamicPricingMode}, NetworkProvider: {NetworkProvider}, CurrentPrice: {CurrentPrice}, PriceToSell: {PriceToSell}, PriceToBuy: {PriceToBuy}, " +
|
|
||||||
$"TimeToSell: {TimeToSellFrom}-{TimeToSellTo}, TimeToBuy: {TimeToBuyFrom}-{TimeToBuyTo}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: SodistoreGrid — update configuration fields when defined
|
|
||||||
public string GetConfigurationSodistoreGrid()
|
|
||||||
{
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CalibrationChargeType
|
public enum CalibrationChargeType
|
||||||
|
|
@ -82,10 +49,10 @@ public enum CalibrationChargeType
|
||||||
ChargePermanently
|
ChargePermanently
|
||||||
}
|
}
|
||||||
|
|
||||||
public class InverterConfig
|
public class DeviceConfigPartial
|
||||||
{
|
{
|
||||||
public Dictionary<string, ClusterConfig> Clusters { get; set; } = new();
|
public Dictionary<string, ClusterConfig>? Clusters { get; set; }
|
||||||
public int PvCount { get; set; }
|
public int? PvCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ClusterConfig
|
public class ClusterConfig
|
||||||
|
|
|
||||||
|
|
@ -448,12 +448,16 @@ public static class ExoCmd
|
||||||
for (int j = 0; j < maxRetransmissions; j++)
|
for (int j = 0; j < maxRetransmissions; j++)
|
||||||
{
|
{
|
||||||
//string message = "This is a message from RabbitMQ server, you can subscribe to the RabbitMQ queue";
|
//string message = "This is a message from RabbitMQ server, you can subscribe to the RabbitMQ queue";
|
||||||
byte[] data = Encoding.UTF8.GetBytes(JsonSerializer.Serialize<Configuration>(config));
|
// Drop null fields so the device only sees what's actually set for this product.
|
||||||
|
var jsonOptions = new System.Text.Json.JsonSerializerOptions
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
var payload = JsonSerializer.Serialize<Configuration>(config, jsonOptions);
|
||||||
|
byte[] data = Encoding.UTF8.GetBytes(payload);
|
||||||
udpClient.Send(data, data.Length, installation.VpnIp, port);
|
udpClient.Send(data, data.Length, installation.VpnIp, port);
|
||||||
|
|
||||||
Console.WriteLine(config.GetConfigurationString());
|
Console.WriteLine($"Sent UDP message to {installation.VpnIp}:{port}: {payload}");
|
||||||
|
|
||||||
Console.WriteLine($"Sent UDP message to {installation.VpnIp}:{port}: {config}");
|
|
||||||
//Console.WriteLine($"Sent UDP message to {installation.VpnIp}:{port}"+" GridSetPoint is "+config.GridSetPoint +" and MinimumSoC is "+config.MinimumSoC);
|
//Console.WriteLine($"Sent UDP message to {installation.VpnIp}:{port}"+" GridSetPoint is "+config.GridSetPoint +" and MinimumSoC is "+config.MinimumSoC);
|
||||||
|
|
||||||
IPEndPoint remoteEndPoint = new IPEndPoint(IPAddress.Parse(installation.VpnIp), port);
|
IPEndPoint remoteEndPoint = new IPEndPoint(IPAddress.Parse(installation.VpnIp), port);
|
||||||
|
|
|
||||||
|
|
@ -707,9 +707,9 @@ export type ConfigurationValues = {
|
||||||
//For sodistoreHome
|
//For sodistoreHome
|
||||||
maximumDischargingCurrent: number;
|
maximumDischargingCurrent: number;
|
||||||
maximumChargingCurrent: number;
|
maximumChargingCurrent: number;
|
||||||
// Nested per-inverter / per-cluster topology + limits (Sinexcel).
|
// Per-inverter Clusters + PvCount, keyed by "Inverter1".."InverterN".
|
||||||
// Keys: "Inverter1".."InverterN" → { Clusters: { "Cluster1".. }, PvCount }
|
// Wire format mirrors the on-disk Devices.InverterN shape — device merges by key.
|
||||||
inverters?: { [inverterKey: string]: InverterConfig };
|
devices?: { [inverterKey: string]: InverterConfig };
|
||||||
operatingPriority: number;
|
operatingPriority: number;
|
||||||
batteriesCount: number;
|
batteriesCount: number;
|
||||||
inverterNumber: number;
|
inverterNumber: number;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ import { fetchDataJson } from '../Installations/fetchData';
|
||||||
import { FetchResult } from '../../../dataCache/dataCache';
|
import { FetchResult } from '../../../dataCache/dataCache';
|
||||||
import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome';
|
import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome';
|
||||||
import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
|
import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
|
||||||
|
import SodistoreHomeConfigurationV2 from './SodistoreHomeConfigurationV2';
|
||||||
|
|
||||||
|
// Pilot installations using the new per-cluster Configuration page (V2).
|
||||||
|
// All other installations keep using the original SodistoreHomeConfiguration (V1).
|
||||||
|
const CONFIG_V2_INSTALLATION_IDS = new Set<number>([790, 839]);
|
||||||
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
||||||
import Overview from '../Overview/overview';
|
import Overview from '../Overview/overview';
|
||||||
import WeeklyReport from './WeeklyReport';
|
import WeeklyReport from './WeeklyReport';
|
||||||
|
|
@ -599,11 +604,19 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
<Route
|
<Route
|
||||||
path={routes.configuration}
|
path={routes.configuration}
|
||||||
element={
|
element={
|
||||||
|
CONFIG_V2_INSTALLATION_IDS.has(props.current_installation.id) ? (
|
||||||
|
<SodistoreHomeConfigurationV2
|
||||||
|
values={values}
|
||||||
|
id={props.current_installation.id}
|
||||||
|
installation={props.current_installation}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<SodistoreHomeConfiguration
|
<SodistoreHomeConfiguration
|
||||||
values={values}
|
values={values}
|
||||||
id={props.current_installation.id}
|
id={props.current_installation.id}
|
||||||
installation={props.current_installation}
|
installation={props.current_installation}
|
||||||
></SodistoreHomeConfiguration>
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
import { ConfigurationValues, InverterConfig, JSONRecordData } from '../Log/graph.util';
|
import { ConfigurationValues, JSONRecordData } from '../Log/graph.util';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
|
||||||
AccordionDetails,
|
|
||||||
AccordionSummary,
|
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
CardContent,
|
CardContent,
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
Divider,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -21,7 +15,6 @@ import {
|
||||||
Typography,
|
Typography,
|
||||||
useTheme
|
useTheme
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
|
|
||||||
import React, { useContext, useState, useEffect } from 'react';
|
import React, { useContext, useState, useEffect } from 'react';
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
@ -32,16 +25,9 @@ import axiosConfig from '../../../Resources/axiosConfig';
|
||||||
import { UserContext } from '../../../contexts/userContext';
|
import { UserContext } from '../../../contexts/userContext';
|
||||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||||
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
||||||
import {
|
|
||||||
buildSodistoreProPreset,
|
|
||||||
getPresetsForDevice,
|
|
||||||
parseBatterySnTree,
|
|
||||||
PresetConfig,
|
|
||||||
BatterySnTree
|
|
||||||
} from '../Information/installationSetupUtils';
|
|
||||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||||
import { DateTimePicker, TimePicker } from '@mui/x-date-pickers';
|
import {DateTimePicker } from '@mui/x-date-pickers';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import Switch from '@mui/material/Switch';
|
import Switch from '@mui/material/Switch';
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
|
@ -72,14 +58,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
'PvPriorityCharging': 'GridPriority',
|
'PvPriorityCharging': 'GridPriority',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dynamic Pricing Mode — backend enum values with UI labels
|
|
||||||
const DynamicPricingOptions = ['Disabled', 'SpotPrice', 'Tou'] as const;
|
|
||||||
const dynamicPricingLabelKey: Record<string, string> = {
|
|
||||||
Disabled: 'dynamicPricingOff',
|
|
||||||
SpotPrice: 'dynamicPricingSpotPrice',
|
|
||||||
Tou: 'dynamicPricingTou',
|
|
||||||
};
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState({
|
const [errors, setErrors] = useState({
|
||||||
minimumSoC: false,
|
minimumSoC: false,
|
||||||
gridSetPoint: false
|
gridSetPoint: false
|
||||||
|
|
@ -92,7 +70,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [updated, setUpdated] = useState(false);
|
const [updated, setUpdated] = useState(false);
|
||||||
|
|
@ -111,77 +88,17 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
// Storage key for pending config (optimistic update)
|
// Storage key for pending config (optimistic update)
|
||||||
const pendingConfigKey = `pendingConfig_${props.id}`;
|
const pendingConfigKey = `pendingConfig_${props.id}`;
|
||||||
|
|
||||||
// Hardware topology — derived from Information tab (single source of truth).
|
|
||||||
const isSodistorePro = product === 5;
|
|
||||||
const installationModel = props.installation.installationModel;
|
|
||||||
const presetConfig: PresetConfig | null = isSodistorePro
|
|
||||||
? (installationModel && parseInt(installationModel, 10) > 0
|
|
||||||
? buildSodistoreProPreset(parseInt(installationModel, 10))
|
|
||||||
: null)
|
|
||||||
: (getPresetsForDevice(device)[installationModel] || null);
|
|
||||||
const inverterCount = presetConfig?.length ?? 1;
|
|
||||||
|
|
||||||
// Build the nested Inverters config from topology (presetConfig).
|
|
||||||
// Used as fallback when the device hasn't yet written the structured Inverters object.
|
|
||||||
const buildInvertersFromPreset = (
|
|
||||||
chargeScalar: number | undefined,
|
|
||||||
dischargeScalar: number | undefined,
|
|
||||||
): { [k: string]: InverterConfig } => {
|
|
||||||
if (!presetConfig) return {};
|
|
||||||
const out: { [k: string]: InverterConfig } = {};
|
|
||||||
presetConfig.forEach((clusters, invIdx) => {
|
|
||||||
const clObj: { [k: string]: any } = {};
|
|
||||||
clusters.forEach((batteryCount, clIdx) => {
|
|
||||||
clObj[`Cluster${clIdx + 1}`] = {
|
|
||||||
BatteryCount: batteryCount,
|
|
||||||
MaxChargingCurrent: chargeScalar ?? 0,
|
|
||||||
MaxDischargingCurrent: dischargeScalar ?? 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
out[`Inverter${invIdx + 1}`] = { Clusters: clObj, PvCount: 0 };
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to build form values from S3 data
|
// Helper to build form values from S3 data
|
||||||
const getS3Values = (): Partial<ConfigurationValues> => {
|
const getS3Values = (): Partial<ConfigurationValues> => ({
|
||||||
const inverterNum = props.values.Config.InverterNumber ?? 1;
|
|
||||||
const batteriesPerInverter: number[] = props.values.Config.BatteriesCountPerInverter
|
|
||||||
?? Array(inverterNum).fill(props.values.Config.BatteriesCount || 1);
|
|
||||||
// Read per-inverter Clusters/PvCount from each Devices.InverterN entry on disk.
|
|
||||||
const cfgDevices = (props.values.Config as any).Devices as { [k: string]: any } | undefined;
|
|
||||||
const cfgInverters: { [k: string]: InverterConfig } | undefined = cfgDevices
|
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(cfgDevices)
|
|
||||||
.filter(([k, v]: [string, any]) => k.startsWith('Inverter') && (v?.Clusters || v?.PvCount != null))
|
|
||||||
.map(([k, v]: [string, any]) => [k, { Clusters: v.Clusters ?? {}, PvCount: v.PvCount ?? 0 }])
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const hasInverterData = cfgInverters && Object.keys(cfgInverters).length > 0;
|
|
||||||
return {
|
|
||||||
minimumSoC: props.values.Config.MinSoc,
|
minimumSoC: props.values.Config.MinSoc,
|
||||||
maximumDischargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
maximumDischargingCurrent: props.values.Config.MaximumDischargingCurrent,
|
||||||
maximumChargingCurrent: props.values.Config.MaximumChargingCurrent,
|
maximumChargingCurrent: props.values.Config.MaximumChargingCurrent,
|
||||||
// Always overlay Information-tab topology so battery counts and PV count
|
|
||||||
// reflect what's actually installed (Information tab is source of truth).
|
|
||||||
inverters: overlayTopology(
|
|
||||||
hasInverterData
|
|
||||||
? cfgInverters
|
|
||||||
: buildInvertersFromPreset(
|
|
||||||
props.values.Config.MaximumChargingCurrent,
|
|
||||||
props.values.Config.MaximumDischargingCurrent,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
operatingPriority: resolveOperatingPriorityIndex(
|
operatingPriority: resolveOperatingPriorityIndex(
|
||||||
props.values.Config.OperatingPriority
|
props.values.Config.OperatingPriority
|
||||||
),
|
),
|
||||||
inverterNumber: inverterNum,
|
|
||||||
batteriesCountPerInverter: batteriesPerInverter,
|
|
||||||
batteriesCount: props.values.Config.BatteriesCount,
|
batteriesCount: props.values.Config.BatteriesCount,
|
||||||
clusterNumber: props.values.Config.ClusterNumber ?? 1,
|
clusterNumber: props.values.Config.ClusterNumber ?? 1,
|
||||||
PvNumber: props.values.Config.PvNumber ?? 0,
|
PvNumber: props.values.Config.PvNumber ?? 0,
|
||||||
pvCountPerInverter: (props.values.Config as any).PvCountPerInverter
|
|
||||||
?? Array(inverterNum).fill(props.values.Config.PvNumber ?? 0),
|
|
||||||
timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0,
|
timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0,
|
||||||
startTimeChargeandDischargeDayandTime: (() => {
|
startTimeChargeandDischargeDayandTime: (() => {
|
||||||
const raw = props.values.Config?.StartTimeChargeandDischargeDayandTime;
|
const raw = props.values.Config?.StartTimeChargeandDischargeDayandTime;
|
||||||
|
|
@ -194,16 +111,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
|
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
|
||||||
})(),
|
})(),
|
||||||
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
||||||
dynamicPricingMode: (props.values.Config as any).DynamicPricingMode ?? 'Disabled',
|
});
|
||||||
currentPrice: (props.values.Config as any).CurrentPrice?.toString() ?? '',
|
|
||||||
priceToSell: (props.values.Config as any).PriceToSell?.toString() ?? '',
|
|
||||||
priceToBuy: (props.values.Config as any).PriceToBuy?.toString() ?? '',
|
|
||||||
timeToSellFrom: (props.values.Config as any).TimeToSellFrom ?? '',
|
|
||||||
timeToSellTo: (props.values.Config as any).TimeToSellTo ?? '',
|
|
||||||
timeToBuyFrom: (props.values.Config as any).TimeToBuyFrom ?? '',
|
|
||||||
timeToBuyTo: (props.values.Config as any).TimeToBuyTo ?? '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Restore pending config from localStorage, converting date strings back to Date objects.
|
// Restore pending config from localStorage, converting date strings back to Date objects.
|
||||||
// Returns { values, s3ConfigSnapshot } or null if no pending config.
|
// Returns { values, s3ConfigSnapshot } or null if no pending config.
|
||||||
|
|
@ -238,40 +146,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
// Fingerprint S3 Config for change detection (not value comparison)
|
// Fingerprint S3 Config for change detection (not value comparison)
|
||||||
const getS3ConfigFingerprint = () => JSON.stringify(props.values.Config);
|
const getS3ConfigFingerprint = () => JSON.stringify(props.values.Config);
|
||||||
|
|
||||||
// Overlay Information-tab-derived topology onto a `inverters` config object.
|
|
||||||
// Battery counts come from the SN tree (filled SNs); PvCount comes from pvStringsPerInverter.
|
|
||||||
// Existing per-cluster current limits are preserved.
|
|
||||||
const overlayTopology = (
|
|
||||||
inverters: { [k: string]: InverterConfig } | undefined
|
|
||||||
): { [k: string]: InverterConfig } | undefined => {
|
|
||||||
if (!presetConfig) return inverters;
|
|
||||||
const tree = parseBatterySnTree(props.installation.batterySerialNumbers || '', presetConfig);
|
|
||||||
const pvStrings = (props.installation.pvStringsPerInverter || '')
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim());
|
|
||||||
const out: { [k: string]: InverterConfig } = {};
|
|
||||||
presetConfig.forEach((clusters, invIdx) => {
|
|
||||||
const invKey = `Inverter${invIdx + 1}`;
|
|
||||||
const existingInv = inverters?.[invKey];
|
|
||||||
const cls: { [k: string]: any } = {};
|
|
||||||
clusters.forEach((_slotCount, clIdx) => {
|
|
||||||
const clKey = `Cluster${clIdx + 1}`;
|
|
||||||
const filled = (tree[invIdx]?.[clIdx] ?? []).filter((s) => s !== '').length;
|
|
||||||
const existingCl = existingInv?.Clusters?.[clKey];
|
|
||||||
cls[clKey] = {
|
|
||||||
BatteryCount: filled,
|
|
||||||
MaxChargingCurrent: existingCl?.MaxChargingCurrent ?? 0,
|
|
||||||
MaxDischargingCurrent: existingCl?.MaxDischargingCurrent ?? 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
out[invKey] = {
|
|
||||||
Clusters: cls,
|
|
||||||
PvCount: parseInt(pvStrings[invIdx] || '0', 10) || 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize form from localStorage (if pending submit exists) or from S3
|
// Initialize form from localStorage (if pending submit exists) or from S3
|
||||||
// This runs in the useState initializer so the component never renders stale values
|
// This runs in the useState initializer so the component never renders stale values
|
||||||
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>(() => {
|
const [formValues, setFormValues] = useState<Partial<ConfigurationValues>>(() => {
|
||||||
|
|
@ -297,10 +171,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
// When S3 data updates (polled every 60s), reconcile with any pending localStorage.
|
// When S3 data updates (polled every 60s), reconcile with any pending localStorage.
|
||||||
// Strategy: device is the authority. Once S3 Config changes from the snapshot taken at
|
// Strategy: device is the authority. Once S3 Config changes from the snapshot taken at
|
||||||
// submit time, the device has uploaded new data — trust S3 regardless of values.
|
// submit time, the device has uploaded new data — trust S3 regardless of values.
|
||||||
// Skip reset if the user is actively editing (formDirty).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (formDirty) return;
|
|
||||||
|
|
||||||
const s3Values = getS3Values();
|
const s3Values = getS3Values();
|
||||||
const pending = restorePendingConfig();
|
const pending = restorePendingConfig();
|
||||||
|
|
||||||
|
|
@ -323,7 +194,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
}, [props.values]);
|
}, [props.values]);
|
||||||
|
|
||||||
const handleOperatingPriorityChange = (event) => {
|
const handleOperatingPriorityChange = (event) => {
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues({
|
setFormValues({
|
||||||
...formValues,
|
...formValues,
|
||||||
['operatingPriority']: OperatingPriorityOptions.indexOf(
|
['operatingPriority']: OperatingPriorityOptions.indexOf(
|
||||||
|
|
@ -359,23 +229,14 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
if (!validateTimeOnly()) {
|
if (!validateTimeOnly()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Re-overlay Information-tab topology at submit time, so battery counts and PV count
|
|
||||||
// are always the latest from the Information tab (it's the source of truth).
|
|
||||||
const inverters = overlayTopology(formValues.inverters);
|
|
||||||
// Pull the first cluster's value as a legacy single-scalar fallback for older firmware.
|
|
||||||
const firstInvKey = inverters ? Object.keys(inverters)[0] : undefined;
|
|
||||||
const firstClusterKey = firstInvKey && inverters
|
|
||||||
? Object.keys(inverters[firstInvKey].Clusters)[0]
|
|
||||||
: undefined;
|
|
||||||
const firstCluster = firstInvKey && firstClusterKey && inverters
|
|
||||||
? inverters[firstInvKey].Clusters[firstClusterKey]
|
|
||||||
: undefined;
|
|
||||||
const configurationToSend: Partial<ConfigurationValues> = {
|
const configurationToSend: Partial<ConfigurationValues> = {
|
||||||
minimumSoC: formValues.minimumSoC,
|
minimumSoC: formValues.minimumSoC,
|
||||||
maximumDischargingCurrent: firstCluster?.MaxDischargingCurrent ?? formValues.maximumDischargingCurrent,
|
maximumDischargingCurrent: formValues.maximumDischargingCurrent,
|
||||||
maximumChargingCurrent: firstCluster?.MaxChargingCurrent ?? formValues.maximumChargingCurrent,
|
maximumChargingCurrent: formValues.maximumChargingCurrent,
|
||||||
inverters,
|
|
||||||
operatingPriority: formValues.operatingPriority,
|
operatingPriority: formValues.operatingPriority,
|
||||||
|
batteriesCount:formValues.batteriesCount,
|
||||||
|
clusterNumber:formValues.clusterNumber,
|
||||||
|
PvNumber:formValues.PvNumber,
|
||||||
timeChargeandDischargePower: formValues.timeChargeandDischargePower,
|
timeChargeandDischargePower: formValues.timeChargeandDischargePower,
|
||||||
startTimeChargeandDischargeDayandTime: formValues.startTimeChargeandDischargeDayandTime
|
startTimeChargeandDischargeDayandTime: formValues.startTimeChargeandDischargeDayandTime
|
||||||
? new Date(formValues.startTimeChargeandDischargeDayandTime.getTime() - formValues.startTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
|
? new Date(formValues.startTimeChargeandDischargeDayandTime.getTime() - formValues.startTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
|
||||||
|
|
@ -383,15 +244,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
stopTimeChargeandDischargeDayandTime: formValues.stopTimeChargeandDischargeDayandTime
|
stopTimeChargeandDischargeDayandTime: formValues.stopTimeChargeandDischargeDayandTime
|
||||||
? new Date(formValues.stopTimeChargeandDischargeDayandTime.getTime() - formValues.stopTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
|
? new Date(formValues.stopTimeChargeandDischargeDayandTime.getTime() - formValues.stopTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
|
||||||
: null,
|
: null,
|
||||||
controlPermission:formValues.controlPermission,
|
controlPermission:formValues.controlPermission
|
||||||
dynamicPricingMode: formValues.dynamicPricingMode,
|
|
||||||
currentPrice: formValues.currentPrice,
|
|
||||||
priceToSell: formValues.priceToSell,
|
|
||||||
priceToBuy: formValues.priceToBuy,
|
|
||||||
timeToSellFrom: formValues.timeToSellFrom,
|
|
||||||
timeToSellTo: formValues.timeToSellTo,
|
|
||||||
timeToBuyFrom: formValues.timeToBuyFrom,
|
|
||||||
timeToBuyTo: formValues.timeToBuyTo,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -410,7 +263,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
if (res) {
|
if (res) {
|
||||||
setUpdated(true);
|
setUpdated(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setFormDirty(false);
|
|
||||||
|
|
||||||
// Save submitted values + S3 snapshot to localStorage for optimistic UI update.
|
// Save submitted values + S3 snapshot to localStorage for optimistic UI update.
|
||||||
// s3ConfigSnapshot = fingerprint of S3 Config at submit time.
|
// s3ConfigSnapshot = fingerprint of S3 Config at submit time.
|
||||||
|
|
@ -425,7 +277,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
setFormDirty(true);
|
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
||||||
if (name === 'minimumSoC') {
|
if (name === 'minimumSoC') {
|
||||||
|
|
@ -458,7 +309,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimeChargeDischargeChange = (name: string, value: any) => {
|
const handleTimeChargeDischargeChange = (name: string, value: any) => {
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({
|
setFormValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[name]: value
|
[name]: value
|
||||||
|
|
@ -538,18 +388,16 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
<Switch
|
<Switch
|
||||||
name="controlPermission"
|
name="controlPermission"
|
||||||
checked={Boolean(formValues.controlPermission)}
|
checked={Boolean(formValues.controlPermission)}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({
|
setFormValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
controlPermission: e.target.checked,
|
controlPermission: e.target.checked,
|
||||||
}));
|
}))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sx={{ transform: "scale(1.4)", marginLeft: "15px" }}
|
sx={{ transform: "scale(1.4)", marginLeft: "15px" }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
sx={{ ml: 0 }}
|
|
||||||
label={
|
label={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="controlPermission"
|
id="controlPermission"
|
||||||
|
|
@ -559,116 +407,55 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '5px' }}>
|
||||||
|
<TextField
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id="batteriesCount "
|
||||||
|
defaultMessage="Batteries Count"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
name="batteriesCount"
|
||||||
|
value={formValues.batteriesCount}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{device === 4 && (
|
{device === 4 && (
|
||||||
<>
|
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
|
||||||
<FormattedMessage id="installationSetup" defaultMessage="Installation Setup" />
|
|
||||||
</Typography>
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{device === 4 && (() => {
|
|
||||||
// Read the SN tree from the Information tab data (single source of truth).
|
|
||||||
// Filled batteries per cluster = entries with a non-empty serial number.
|
|
||||||
const tree: BatterySnTree | null = presetConfig
|
|
||||||
? parseBatterySnTree(props.installation.batterySerialNumbers || '', presetConfig)
|
|
||||||
: null;
|
|
||||||
// PV strings per inverter — comma-separated string from Information tab.
|
|
||||||
const pvStrings = (props.installation.pvStringsPerInverter || '')
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: '5px' }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label={intl.formatMessage({ id: 'inverterNumber' })}
|
label={
|
||||||
value={inverterCount}
|
<FormattedMessage
|
||||||
InputProps={{ readOnly: true }}
|
id="clusterNumber"
|
||||||
|
defaultMessage="Cluster Number"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
name="clusterNumber"
|
||||||
|
value={formValues.clusterNumber}
|
||||||
|
onChange={handleChange}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Array.from({ length: inverterCount }, (_, i) => {
|
<div style={{ marginBottom: '5px' }}>
|
||||||
const clusters = presetConfig?.[i] ?? [];
|
|
||||||
const treeForInverter = tree?.[i] ?? [];
|
|
||||||
const filledBat = treeForInverter.flat().filter((s) => s !== '').length;
|
|
||||||
const totalBat = clusters.reduce((a, b) => a + b, 0);
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
key={`inv-${i}`}
|
|
||||||
defaultExpanded={false}
|
|
||||||
sx={{ ml: 1, mr: 1, mt: 1 }}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
'& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' },
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
'& .MuiAccordionSummary-expandIconWrapper': { ml: 1 }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontWeight: 'bold' }}>
|
|
||||||
<FormattedMessage id="inverterN" defaultMessage="Inverter {n}" values={{ n: i + 1 }} />
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
label={`${filledBat} ${intl.formatMessage({ id: 'batteries', defaultMessage: 'batteries' })}`}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
/>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
{clusters.map((_slotCount, clIdx) => {
|
|
||||||
const filledInCluster = (treeForInverter[clIdx] ?? [])
|
|
||||||
.filter((s) => s !== '').length;
|
|
||||||
return (
|
|
||||||
<div key={`cl-${i}-${clIdx}`} style={{ marginBottom: '5px' }}>
|
|
||||||
<TextField
|
<TextField
|
||||||
label={intl.formatMessage(
|
label={
|
||||||
{ id: 'batteryNumberInClusterN', defaultMessage: 'Battery Number in Cluster {n}' },
|
<FormattedMessage
|
||||||
{ n: clIdx + 1 }
|
id="PvNumber"
|
||||||
)}
|
defaultMessage="PV Number"
|
||||||
value={filledInCluster}
|
/>
|
||||||
InputProps={{ readOnly: true }}
|
}
|
||||||
|
name="PvNumber"
|
||||||
|
value={formValues.PvNumber}
|
||||||
|
onChange={handleChange}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(() => {
|
|
||||||
const pvCount = parseInt(pvStrings[i] || '0', 10) || 0;
|
|
||||||
return (
|
|
||||||
<div key={`pv-${i}`} style={{ marginBottom: '5px' }}>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage(
|
|
||||||
{ id: 'pvStringsNumberInInverterN', defaultMessage: 'PV Strings Number in Inverter {n}' },
|
|
||||||
{ n: i + 1 }
|
|
||||||
)}
|
|
||||||
value={pvCount}
|
|
||||||
InputProps={{ readOnly: true }}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
);
|
)}
|
||||||
})()}
|
|
||||||
|
|
||||||
{device === 4 && (
|
|
||||||
<>
|
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
|
||||||
<FormattedMessage id="batteryLimits" defaultMessage="Battery Limits" />
|
|
||||||
</Typography>
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
{/*<TextField*/}
|
{/*<TextField*/}
|
||||||
|
|
@ -704,88 +491,14 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{device === 4 ? (
|
|
||||||
// Per-cluster, per-inverter charging/discharging current limits — nested config.
|
|
||||||
Array.from({ length: inverterCount }, (_, invIdx) => {
|
|
||||||
const invKey = `Inverter${invIdx + 1}`;
|
|
||||||
const clusters = presetConfig?.[invIdx] ?? [0];
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
key={`limits-${invKey}`}
|
|
||||||
defaultExpanded={false}
|
|
||||||
sx={{ ml: 1, mr: 1, mt: 1 }}
|
|
||||||
>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
'& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' },
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
'& .MuiAccordionSummary-expandIconWrapper': { ml: 1 }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontWeight: 'bold' }}>
|
|
||||||
<FormattedMessage id="inverterN" defaultMessage="Inverter {n}" values={{ n: invIdx + 1 }} />
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
{clusters.map((_slotCount, clIdx) => {
|
|
||||||
const clKey = `Cluster${clIdx + 1}`;
|
|
||||||
const cluster = formValues.inverters?.[invKey]?.Clusters?.[clKey];
|
|
||||||
const charge = cluster?.MaxChargingCurrent ?? '';
|
|
||||||
const discharge = cluster?.MaxDischargingCurrent ?? '';
|
|
||||||
const setClusterField = (
|
|
||||||
field: 'MaxChargingCurrent' | 'MaxDischargingCurrent',
|
|
||||||
v: string
|
|
||||||
) => {
|
|
||||||
if (v !== '' && !/^\d*\.?\d*$/.test(v)) return;
|
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => {
|
|
||||||
const inverters = { ...(prev.inverters ?? {}) };
|
|
||||||
const inv = inverters[invKey]
|
|
||||||
?? { Clusters: {}, PvCount: 0 };
|
|
||||||
const cls = { ...(inv.Clusters ?? {}) };
|
|
||||||
const existing = cls[clKey] ?? {
|
|
||||||
BatteryCount: presetConfig?.[invIdx]?.[clIdx] ?? 0,
|
|
||||||
MaxChargingCurrent: 0,
|
|
||||||
MaxDischargingCurrent: 0,
|
|
||||||
};
|
|
||||||
cls[clKey] = {
|
|
||||||
...existing,
|
|
||||||
[field]: v === '' ? 0 : parseFloat(v),
|
|
||||||
};
|
|
||||||
inverters[invKey] = { ...inv, Clusters: cls };
|
|
||||||
return { ...prev, inverters };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div key={`cl-limits-${invKey}-${clKey}`} style={{ marginBottom: '5px' }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ mt: 1, ml: 1 }}>
|
|
||||||
<FormattedMessage id="clusterN" defaultMessage="Cluster {n}" values={{ n: clIdx + 1 }} />
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'maximumChargingCurrentPerClusterLabel' })}
|
|
||||||
value={charge}
|
|
||||||
onChange={(e) => setClusterField('MaxChargingCurrent', e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'maximumDischargingCurrentPerClusterLabel' })}
|
|
||||||
value={discharge}
|
|
||||||
onChange={(e) => setClusterField('MaxDischargingCurrent', e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label={intl.formatMessage({ id: 'maximumChargingCurrentPerBattery' })}
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id="maximumChargingCurrent "
|
||||||
|
defaultMessage="Maximum Charging Current"
|
||||||
|
/>
|
||||||
|
}
|
||||||
name="maximumChargingCurrent"
|
name="maximumChargingCurrent"
|
||||||
value={formValues.maximumChargingCurrent}
|
value={formValues.maximumChargingCurrent}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
@ -795,24 +508,18 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label={intl.formatMessage({ id: 'maximumDischargingCurrentPerBattery' })}
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id="maximumDischargingCurrent "
|
||||||
|
defaultMessage="Maximum Discharging Current"
|
||||||
|
/>
|
||||||
|
}
|
||||||
name="maximumDischargingCurrent"
|
name="maximumDischargingCurrent"
|
||||||
value={formValues.maximumDischargingCurrent}
|
value={formValues.maximumDischargingCurrent}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{device === 4 && (
|
|
||||||
<>
|
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
|
||||||
<FormattedMessage id="systemSettings" defaultMessage="System Settings" />
|
|
||||||
</Typography>
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px', marginTop: '10px' }}>
|
<div style={{ marginBottom: '5px', marginTop: '10px' }}>
|
||||||
<FormControl fullWidth sx={{ marginLeft: 1, width: 390 }}>
|
<FormControl fullWidth sx={{ marginLeft: 1, width: 390 }}>
|
||||||
|
|
@ -851,13 +558,13 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
{/* Power input*/}
|
{/* Power input*/}
|
||||||
<div style={{ marginBottom: '5px' }}>
|
<div style={{ marginBottom: '5px' }}>
|
||||||
<TextField
|
<TextField
|
||||||
label={intl.formatMessage({ id: 'powerPerInverterKW' })}
|
label={intl.formatMessage({ id: 'powerW' })}
|
||||||
name="timeChargeandDischargePower"
|
name="timeChargeandDischargePower"
|
||||||
value={formValues.timeChargeandDischargePower}
|
value={formValues.timeChargeandDischargePower}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleTimeChargeDischargeChange(e.target.name, e.target.value)
|
handleTimeChargeDischargeChange(e.target.name, e.target.value)
|
||||||
}
|
}
|
||||||
helperText={intl.formatMessage({ id: 'perInverter' })}
|
helperText={intl.formatMessage({ id: 'enterPowerValue' })}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -928,160 +635,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* --- Sinexcel + LoadPriority: Dynamic Pricing --- */}
|
|
||||||
{device === 4 &&
|
|
||||||
OperatingPriorityOptions[formValues.operatingPriority] === 'LoadPriority' && (
|
|
||||||
<>
|
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
|
||||||
<FormattedMessage id="dynamicPricing" defaultMessage="Dynamic Pricing" />
|
|
||||||
</Typography>
|
|
||||||
<Divider sx={{ mb: 2 }} />
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px', marginTop: '10px' }}>
|
|
||||||
<FormControl fullWidth sx={{ marginLeft: 1, width: 390 }}>
|
|
||||||
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
|
||||||
<FormattedMessage id="dynamicPricingMode" defaultMessage="Dynamic Pricing Mode" />
|
|
||||||
</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={formValues.dynamicPricingMode ?? 'Disabled'}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
dynamicPricingMode: e.target.value as string,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{DynamicPricingOptions.map((opt) => (
|
|
||||||
<MenuItem key={opt} value={opt}>
|
|
||||||
{intl.formatMessage({ id: dynamicPricingLabelKey[opt] })}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formValues.dynamicPricingMode === 'Tou' && (
|
|
||||||
<>
|
|
||||||
{(() => {
|
|
||||||
const renderTimeField = (
|
|
||||||
labelId: string,
|
|
||||||
key: 'timeToSellFrom' | 'timeToSellTo' | 'timeToBuyFrom' | 'timeToBuyTo'
|
|
||||||
) => {
|
|
||||||
const raw = formValues[key];
|
|
||||||
const parsed = raw ? dayjs(raw, 'HH:mm') : null;
|
|
||||||
return (
|
|
||||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
|
||||||
<TimePicker
|
|
||||||
ampm={false}
|
|
||||||
label={intl.formatMessage({ id: labelId })}
|
|
||||||
value={parsed && parsed.isValid() ? parsed : null}
|
|
||||||
onChange={(newValue) => {
|
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[key]: newValue ? newValue.format('HH:mm') : '',
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} sx={{ marginTop: 2, width: '100%' }} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</LocalizationProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Typography variant="subtitle2" sx={{ mt: 2, ml: 1 }}>
|
|
||||||
<FormattedMessage id="timeToSell" defaultMessage="Time to Sell" />
|
|
||||||
</Typography>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginLeft: 8 }}>
|
|
||||||
{renderTimeField('timeFrom', 'timeToSellFrom')}
|
|
||||||
{renderTimeField('timeTo', 'timeToSellTo')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Typography variant="subtitle2" sx={{ mt: 2, ml: 1 }}>
|
|
||||||
<FormattedMessage id="timeToBuy" defaultMessage="Time to Buy" />
|
|
||||||
</Typography>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginLeft: 8 }}>
|
|
||||||
{renderTimeField('timeFrom', 'timeToBuyFrom')}
|
|
||||||
{renderTimeField('timeTo', 'timeToBuyTo')}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formValues.dynamicPricingMode === 'SpotPrice' && (
|
|
||||||
<>
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'networkProvider' })}
|
|
||||||
value={
|
|
||||||
props.installation.networkProvider ||
|
|
||||||
intl.formatMessage({ id: 'networkProviderSetOnInformationTab' })
|
|
||||||
}
|
|
||||||
InputProps={{ readOnly: true }}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'currentPrice' })}
|
|
||||||
value={(props.values.Config as any).CurrentPrice?.toString() ?? ''}
|
|
||||||
InputProps={{
|
|
||||||
readOnly: true,
|
|
||||||
endAdornment: <InputAdornment position="end">CHF/kWh</InputAdornment>,
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'priceToSell' })}
|
|
||||||
name="priceToSell"
|
|
||||||
value={formValues.priceToSell ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
if (v === '' || /^\d*\.?\d*$/.test(v)) {
|
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({ ...prev, priceToSell: v }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: <InputAdornment position="end">CHF/kWh</InputAdornment>,
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '5px' }}>
|
|
||||||
<TextField
|
|
||||||
label={intl.formatMessage({ id: 'priceToBuy' })}
|
|
||||||
name="priceToBuy"
|
|
||||||
value={formValues.priceToBuy ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
if (v === '' || /^\d*\.?\d*$/.test(v)) {
|
|
||||||
setFormDirty(true);
|
|
||||||
setFormValues((prev) => ({ ...prev, priceToBuy: v }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: <InputAdornment position="end">CHF/kWh</InputAdornment>,
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue