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;
}
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
3 => config.GetConfigurationSodistoreMax(), // SodiStoreMax
2 => config.GetConfigurationSodistoreHome(), // SodiStoreHome
4 => config.GetConfigurationSodistoreGrid(), // SodistoreGrid
_ => config.GetConfigurationString() // fallback
};
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
});
Console.WriteLine("CONFIG IS " + configString);

View File

@ -9,17 +9,20 @@ public class Configuration
public CalibrationChargeType? CalibrationDischargeState { 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? 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 double? BatteriesCount { get; set; }
public List<int>? BatteriesCountPerInverter { get; set; }
public double? ClusterNumber { 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 double? TimeChargeandDischargePower { get; set; }
public DateTime? StartTimeChargeandDischargeDayandTime { get; set; }
@ -37,42 +40,6 @@ public class Configuration
public string? TimeToBuyFrom { 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
@ -82,10 +49,10 @@ public enum CalibrationChargeType
ChargePermanently
}
public class InverterConfig
public class DeviceConfigPartial
{
public Dictionary<string, ClusterConfig> Clusters { get; set; } = new();
public int PvCount { get; set; }
public Dictionary<string, ClusterConfig>? Clusters { get; set; }
public int? PvCount { get; set; }
}
public class ClusterConfig

View File

@ -448,12 +448,16 @@ public static class ExoCmd
for (int j = 0; j < maxRetransmissions; j++)
{
//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);
Console.WriteLine(config.GetConfigurationString());
Console.WriteLine($"Sent UDP message to {installation.VpnIp}:{port}: {config}");
Console.WriteLine($"Sent UDP message to {installation.VpnIp}:{port}: {payload}");
//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);

View File

@ -707,9 +707,9 @@ export type ConfigurationValues = {
//For sodistoreHome
maximumDischargingCurrent: number;
maximumChargingCurrent: number;
// Nested per-inverter / per-cluster topology + limits (Sinexcel).
// Keys: "Inverter1".."InverterN" → { Clusters: { "Cluster1".. }, PvCount }
inverters?: { [inverterKey: string]: InverterConfig };
// Per-inverter Clusters + PvCount, keyed by "Inverter1".."InverterN".
// Wire format mirrors the on-disk Devices.InverterN shape — device merges by key.
devices?: { [inverterKey: string]: InverterConfig };
operatingPriority: number;
batteriesCount: number;
inverterNumber: number;

View File

@ -25,6 +25,11 @@ import { fetchDataJson } from '../Installations/fetchData';
import { FetchResult } from '../../../dataCache/dataCache';
import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome';
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 Overview from '../Overview/overview';
import WeeklyReport from './WeeklyReport';
@ -599,11 +604,19 @@ function SodioHomeInstallation(props: singleInstallationProps) {
<Route
path={routes.configuration}
element={
<SodistoreHomeConfiguration
values={values}
id={props.current_installation.id}
installation={props.current_installation}
></SodistoreHomeConfiguration>
CONFIG_V2_INSTALLATION_IDS.has(props.current_installation.id) ? (
<SodistoreHomeConfigurationV2
values={values}
id={props.current_installation.id}
installation={props.current_installation}
/>
) : (
<SodistoreHomeConfiguration
values={values}
id={props.current_installation.id}
installation={props.current_installation}
/>
)
}
/>
)}

View File

@ -1,19 +1,13 @@
import { ConfigurationValues, InverterConfig, JSONRecordData } from '../Log/graph.util';
import { ConfigurationValues, JSONRecordData } from '../Log/graph.util';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
CardContent,
Chip,
CircularProgress,
Container,
Divider,
FormControl,
Grid,
IconButton,
InputAdornment,
InputLabel,
Modal,
Select,
@ -21,7 +15,6 @@ import {
Typography,
useTheme
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import React, { useContext, useState, useEffect } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
@ -32,16 +25,9 @@ import axiosConfig from '../../../Resources/axiosConfig';
import { UserContext } from '../../../contexts/userContext';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
import { I_Installation } from 'src/interfaces/InstallationTypes';
import {
buildSodistoreProPreset,
getPresetsForDevice,
parseBatterySnTree,
PresetConfig,
BatterySnTree
} from '../Information/installationSetupUtils';
import { LocalizationProvider } from '@mui/x-date-pickers';
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 Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
@ -72,14 +58,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
'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({
minimumSoC: false,
gridSetPoint: false
@ -92,7 +70,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
}));
};
const theme = useTheme();
const [formDirty, setFormDirty] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [updated, setUpdated] = useState(false);
@ -111,99 +88,30 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
// Storage key for pending config (optimistic update)
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
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,
maximumDischargingCurrent: props.values.Config.MaximumDischargingCurrent,
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(
props.values.Config.OperatingPriority
),
inverterNumber: inverterNum,
batteriesCountPerInverter: batteriesPerInverter,
batteriesCount: props.values.Config.BatteriesCount,
clusterNumber: props.values.Config.ClusterNumber ?? 1,
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,
startTimeChargeandDischargeDayandTime: (() => {
const raw = props.values.Config?.StartTimeChargeandDischargeDayandTime;
const parsed = raw ? dayjs(raw) : null;
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
})(),
stopTimeChargeandDischargeDayandTime: (() => {
const raw = props.values.Config?.StopTimeChargeandDischargeDayandTime;
const parsed = raw ? dayjs(raw) : null;
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
})(),
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 ?? '',
};
};
const getS3Values = (): Partial<ConfigurationValues> => ({
minimumSoC: props.values.Config.MinSoc,
maximumDischargingCurrent: props.values.Config.MaximumDischargingCurrent,
maximumChargingCurrent: props.values.Config.MaximumChargingCurrent,
operatingPriority: resolveOperatingPriorityIndex(
props.values.Config.OperatingPriority
),
batteriesCount: props.values.Config.BatteriesCount,
clusterNumber: props.values.Config.ClusterNumber ?? 1,
PvNumber: props.values.Config.PvNumber ?? 0,
timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0,
startTimeChargeandDischargeDayandTime: (() => {
const raw = props.values.Config?.StartTimeChargeandDischargeDayandTime;
const parsed = raw ? dayjs(raw) : null;
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
})(),
stopTimeChargeandDischargeDayandTime: (() => {
const raw = props.values.Config?.StopTimeChargeandDischargeDayandTime;
const parsed = raw ? dayjs(raw) : null;
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
})(),
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
});
// Restore pending config from localStorage, converting date strings back to Date objects.
// 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)
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
// This runs in the useState initializer so the component never renders stale values
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.
// 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.
// Skip reset if the user is actively editing (formDirty).
useEffect(() => {
if (formDirty) return;
const s3Values = getS3Values();
const pending = restorePendingConfig();
@ -323,7 +194,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
}, [props.values]);
const handleOperatingPriorityChange = (event) => {
setFormDirty(true);
setFormValues({
...formValues,
['operatingPriority']: OperatingPriorityOptions.indexOf(
@ -359,23 +229,14 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
if (!validateTimeOnly()) {
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> = {
minimumSoC: formValues.minimumSoC,
maximumDischargingCurrent: firstCluster?.MaxDischargingCurrent ?? formValues.maximumDischargingCurrent,
maximumChargingCurrent: firstCluster?.MaxChargingCurrent ?? formValues.maximumChargingCurrent,
inverters,
maximumDischargingCurrent: formValues.maximumDischargingCurrent,
maximumChargingCurrent: formValues.maximumChargingCurrent,
operatingPriority: formValues.operatingPriority,
batteriesCount:formValues.batteriesCount,
clusterNumber:formValues.clusterNumber,
PvNumber:formValues.PvNumber,
timeChargeandDischargePower: formValues.timeChargeandDischargePower,
startTimeChargeandDischargeDayandTime: formValues.startTimeChargeandDischargeDayandTime
? new Date(formValues.startTimeChargeandDischargeDayandTime.getTime() - formValues.startTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
@ -383,15 +244,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
stopTimeChargeandDischargeDayandTime: formValues.stopTimeChargeandDischargeDayandTime
? new Date(formValues.stopTimeChargeandDischargeDayandTime.getTime() - formValues.stopTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
: null,
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,
controlPermission:formValues.controlPermission
};
setLoading(true);
@ -410,7 +263,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
if (res) {
setUpdated(true);
setLoading(false);
setFormDirty(false);
// Save submitted values + S3 snapshot to localStorage for optimistic UI update.
// s3ConfigSnapshot = fingerprint of S3 Config at submit time.
@ -425,7 +277,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
};
const handleChange = (e) => {
setFormDirty(true);
const { name, value } = e.target;
if (name === 'minimumSoC') {
@ -458,7 +309,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
};
const handleTimeChargeDischargeChange = (name: string, value: any) => {
setFormDirty(true);
setFormValues((prev) => ({
...prev,
[name]: value
@ -538,18 +388,16 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
<Switch
name="controlPermission"
checked={Boolean(formValues.controlPermission)}
onChange={(e) => {
setFormDirty(true);
onChange={(e) =>
setFormValues((prev) => ({
...prev,
controlPermission: e.target.checked,
}));
}
}))
}
sx={{ transform: "scale(1.4)", marginLeft: "15px" }}
/>
}
sx={{ ml: 0 }}
label={
<FormattedMessage
id="controlPermission"
@ -559,117 +407,56 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
/>
</div>
{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' }}>
<TextField
label={intl.formatMessage({ id: 'inverterNumber' })}
value={inverterCount}
InputProps={{ readOnly: true }}
fullWidth
/>
</div>
{Array.from({ length: inverterCount }, (_, i) => {
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
label={intl.formatMessage(
{ id: 'batteryNumberInClusterN', defaultMessage: 'Battery Number in Cluster {n}' },
{ n: clIdx + 1 }
)}
value={filledInCluster}
InputProps={{ readOnly: true }}
fullWidth
/>
</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>
);
})}
</>
);
})()}
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="batteriesCount "
defaultMessage="Batteries Count"
/>
}
name="batteriesCount"
value={formValues.batteriesCount}
onChange={handleChange}
fullWidth
/>
</div>
{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' }}>
<TextField
label={
<FormattedMessage
id="clusterNumber"
defaultMessage="Cluster Number"
/>
}
name="clusterNumber"
value={formValues.clusterNumber}
onChange={handleChange}
fullWidth
/>
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="PvNumber"
defaultMessage="PV Number"
/>
}
name="PvNumber"
value={formValues.PvNumber}
onChange={handleChange}
fullWidth
/>
</div>
</>
)}
<div style={{ marginBottom: '5px' }}>
{/*<TextField*/}
{/* label={*/}
@ -704,115 +491,35 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
</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' }}>
<TextField
label={intl.formatMessage({ id: 'maximumChargingCurrentPerBattery' })}
name="maximumChargingCurrent"
value={formValues.maximumChargingCurrent}
onChange={handleChange}
fullWidth
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="maximumChargingCurrent "
defaultMessage="Maximum Charging Current"
/>
</div>
}
name="maximumChargingCurrent"
value={formValues.maximumChargingCurrent}
onChange={handleChange}
fullWidth
/>
</div>
<div style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage({ id: 'maximumDischargingCurrentPerBattery' })}
name="maximumDischargingCurrent"
value={formValues.maximumDischargingCurrent}
onChange={handleChange}
fullWidth
<div style={{ marginBottom: '5px' }}>
<TextField
label={
<FormattedMessage
id="maximumDischargingCurrent "
defaultMessage="Maximum Discharging Current"
/>
</div>
</>
)}
{device === 4 && (
<>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
<FormattedMessage id="systemSettings" defaultMessage="System Settings" />
</Typography>
<Divider sx={{ mb: 2 }} />
</>
)}
}
name="maximumDischargingCurrent"
value={formValues.maximumDischargingCurrent}
onChange={handleChange}
fullWidth
/>
</div>
<div style={{ marginBottom: '5px', marginTop: '10px' }}>
<FormControl fullWidth sx={{ marginLeft: 1, width: 390 }}>
@ -851,13 +558,13 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
{/* Power input*/}
<div style={{ marginBottom: '5px' }}>
<TextField
label={intl.formatMessage({ id: 'powerPerInverterKW' })}
label={intl.formatMessage({ id: 'powerW' })}
name="timeChargeandDischargePower"
value={formValues.timeChargeandDischargePower}
onChange={(e) =>
handleTimeChargeDischargeChange(e.target.name, e.target.value)
}
helperText={intl.formatMessage({ id: 'perInverter' })}
helperText={intl.formatMessage({ id: 'enterPowerValue' })}
fullWidth
/>
</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
style={{
display: 'flex',