Introduce a new Configuration page for

prototype installations . All other installations
keep the original one unchanged.
This commit is contained in:
Yinyin Liu 2026-05-04 17:51:34 +02:00
parent 52bc06ccb7
commit 2a258ae0e2
7 changed files with 1177 additions and 615 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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