From ce7a9d3cf22c73033aad8879a41816f252e0afa2 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 19 Mar 2026 11:32:18 +0100 Subject: [PATCH 1/6] improve multi-inverter configurtaion page - add inverter number and fix unit issue --- csharp/App/Backend/DataTypes/Configuration.cs | 10 +- .../src/content/dashboards/Log/graph.util.tsx | 4 + .../SodistoreHomeConfiguration.tsx | 147 ++++++++++++------ typescript/frontend-marios2/src/lang/de.json | 5 + typescript/frontend-marios2/src/lang/en.json | 5 + typescript/frontend-marios2/src/lang/fr.json | 5 + typescript/frontend-marios2/src/lang/it.json | 5 + 7 files changed, 130 insertions(+), 51 deletions(-) diff --git a/csharp/App/Backend/DataTypes/Configuration.cs b/csharp/App/Backend/DataTypes/Configuration.cs index 5c0726ca5..8ba1d7a43 100644 --- a/csharp/App/Backend/DataTypes/Configuration.cs +++ b/csharp/App/Backend/DataTypes/Configuration.cs @@ -12,7 +12,9 @@ public class Configuration public double? MaximumDischargingCurrent { get; set; } public double? MaximumChargingCurrent { get; set; } public double? OperatingPriority { get; set; } + public int? InverterNumber { get; set; } public double? BatteriesCount { get; set; } + public List? BatteriesCountPerInverter { get; set; } public double? ClusterNumber { get; set; } public double? PvNumber { get; set; } public bool ControlPermission { get; set; } @@ -25,11 +27,11 @@ public class Configuration return $"MinimumSoC: {MinimumSoC}, GridSetPoint: {GridSetPoint}, CalibrationChargeState: {CalibrationChargeState}, CalibrationChargeDate: {CalibrationChargeDate}, " + $"CalibrationDischargeState: {CalibrationDischargeState}, CalibrationDischargeDate: {CalibrationDischargeDate}, " + $"MaximumDischargingCurrent: {MaximumDischargingCurrent}, MaximumChargingCurrent: {MaximumChargingCurrent}, OperatingPriority: {OperatingPriority}, " + - $"BatteriesCount: {BatteriesCount}, ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+ + $"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 @@ -45,7 +47,7 @@ public class Configuration public string GetConfigurationSodistoreHome() { return $"MinimumSoC: {MinimumSoC}, MaximumDischargingCurrent: {MaximumDischargingCurrent}, MaximumChargingCurrent: {MaximumChargingCurrent}, OperatingPriority: {OperatingPriority}, " + - $"BatteriesCount: {BatteriesCount}, ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+ + $"InverterNumber: {InverterNumber}, BatteriesCount: {BatteriesCount}, BatteriesCountPerInverter: [{(BatteriesCountPerInverter != null ? string.Join(", ", BatteriesCountPerInverter) : "")}], ClusterNumber: {ClusterNumber}, PvNumber: {PvNumber}, ControlPermission:{ControlPermission}, "+ $"SinexcelTimeChargeandDischargePower: {TimeChargeandDischargePower}, SinexcelStartTimeChargeandDischargeDayandTime: {StartTimeChargeandDischargeDayandTime}, SinexcelStopTimeChargeandDischargeDayandTime: {StopTimeChargeandDischargeDayandTime}"; } diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx index 0f8124d56..e74ca9df3 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx @@ -329,6 +329,8 @@ export interface JSONRecordData { MaximumDischargingCurrent: number; OperatingPriority: string; BatteriesCount: number; + InverterNumber?: number; + BatteriesCountPerInverter?: number[]; ClusterNumber: number; PvNumber: number; ControlPermission:boolean; @@ -696,6 +698,8 @@ export type ConfigurationValues = { maximumChargingCurrent: number; operatingPriority: number; batteriesCount: number; + inverterNumber: number; + batteriesCountPerInverter: number[]; clusterNumber: number; PvNumber: number; controlPermission:boolean; diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx index 620a8f388..1a0351107 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx @@ -70,6 +70,7 @@ 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); @@ -89,27 +90,34 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { const pendingConfigKey = `pendingConfig_${props.id}`; // Helper to build form values from S3 data - const getS3Values = (): Partial => ({ - minimumSoC: props.values.Config.MinSoc, - maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent, - maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent, - 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: - props.values.Config?.StartTimeChargeandDischargeDayandTime - ? dayjs(props.values.Config.StartTimeChargeandDischargeDayandTime).toDate() - : null, - stopTimeChargeandDischargeDayandTime: - props.values.Config?.StopTimeChargeandDischargeDayandTime - ? dayjs(props.values.Config.StopTimeChargeandDischargeDayandTime).toDate() - : null, - controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true", - }); + const getS3Values = (): Partial => { + const inverterNum = props.values.Config.InverterNumber ?? 1; + const batteriesPerInverter: number[] = props.values.Config.BatteriesCountPerInverter + ?? Array(inverterNum).fill(props.values.Config.BatteriesCount || 1); + return { + minimumSoC: props.values.Config.MinSoc, + maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent, + 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, + timeChargeandDischargePower: props.values.Config?.TimeChargeandDischargePower ?? 0, + startTimeChargeandDischargeDayandTime: + props.values.Config?.StartTimeChargeandDischargeDayandTime + ? dayjs(props.values.Config.StartTimeChargeandDischargeDayandTime).toDate() + : null, + stopTimeChargeandDischargeDayandTime: + props.values.Config?.StopTimeChargeandDischargeDayandTime + ? dayjs(props.values.Config.StopTimeChargeandDischargeDayandTime).toDate() + : null, + 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. @@ -169,7 +177,10 @@ 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(); @@ -192,6 +203,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { }, [props.values]); const handleOperatingPriorityChange = (event) => { + setFormDirty(true); setFormValues({ ...formValues, ['operatingPriority']: OperatingPriorityOptions.indexOf( @@ -230,7 +242,9 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { maximumDischargingCurrent: formValues.maximumDischargingCurrent, maximumChargingCurrent: formValues.maximumChargingCurrent, operatingPriority: formValues.operatingPriority, - batteriesCount:formValues.batteriesCount, + inverterNumber: formValues.inverterNumber, + batteriesCountPerInverter: formValues.batteriesCountPerInverter, + batteriesCount: formValues.batteriesCountPerInverter?.[0] ?? formValues.batteriesCount, clusterNumber:formValues.clusterNumber, PvNumber:formValues.PvNumber, timeChargeandDischargePower: formValues.timeChargeandDischargePower, @@ -259,6 +273,7 @@ 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. @@ -273,6 +288,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { }; const handleChange = (e) => { + setFormDirty(true); const { name, value } = e.target; if (name === 'minimumSoC') { @@ -305,6 +321,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { }; const handleTimeChargeDischargeChange = (name: string, value: any) => { + setFormDirty(true); setFormValues((prev) => ({ ...prev, [name]: value @@ -384,11 +401,13 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { + onChange={(e) => { + setFormDirty(true); setFormValues((prev) => ({ ...prev, controlPermission: e.target.checked, - })) + })); + } } sx={{ transform: "scale(1.4)", marginLeft: "15px" }} /> @@ -405,19 +424,63 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
- } - name="batteriesCount" - value={formValues.batteriesCount} - onChange={handleChange} + label={intl.formatMessage({ id: 'inverterNumber' })} + name="inverterNumber" + value={formValues.inverterNumber ?? ''} + onChange={(e) => { + setFormDirty(true); + const raw = e.target.value; + if (raw === '') { + setFormValues((prev) => ({ ...prev, inverterNumber: '' as any })); + return; + } + const parsed = parseInt(raw); + if (isNaN(parsed) || parsed < 1) return; + const currentArr = formValues.batteriesCountPerInverter || [1]; + const newArr = Array.from({ length: parsed }, (_, i) => currentArr[i] ?? 1); + setFormValues((prev) => ({ + ...prev, + inverterNumber: parsed, + batteriesCountPerInverter: newArr, + })); + }} fullWidth />
+ {Array.from({ length: formValues.inverterNumber ?? 1 }, (_, i) => ( +
+ { + setFormDirty(true); + const raw = e.target.value; + if (raw === '') { + setFormValues((prev) => { + const arr = [...(prev.batteriesCountPerInverter || [1])]; + arr[i] = '' as any; + return { ...prev, batteriesCountPerInverter: arr }; + }); + return; + } + const parsed = parseInt(raw); + if (isNaN(parsed) || parsed < 1) return; + setFormValues((prev) => { + const arr = [...(prev.batteriesCountPerInverter || [1])]; + arr[i] = parsed; + return { ...prev, batteriesCountPerInverter: arr }; + }); + }} + fullWidth + /> +
+ ))} + {device === 4 && ( <>
@@ -489,12 +552,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
- } + label={intl.formatMessage({ id: 'maximumChargingCurrentPerBattery' })} name="maximumChargingCurrent" value={formValues.maximumChargingCurrent} onChange={handleChange} @@ -504,12 +562,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
- } + label={intl.formatMessage({ id: 'maximumDischargingCurrentPerBattery' })} name="maximumDischargingCurrent" value={formValues.maximumDischargingCurrent} onChange={handleChange} @@ -554,13 +607,13 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { {/* Power input*/}
handleTimeChargeDischargeChange(e.target.name, e.target.value) } - helperText={intl.formatMessage({ id: 'enterPowerValue' })} + helperText={intl.formatMessage({ id: 'perInverter' })} fullWidth />
diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 1bab475d6..fe64612e4 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -486,6 +486,11 @@ "minimumSocPercent": "Minimaler Ladezustand (%)", "powerW": "Leistung (W)", "enterPowerValue": "Positiven oder negativen Leistungswert eingeben", + "inverterNumber": "Anzahl Wechselrichter", + "batteriesCountInInverter": "Batterieanzahl in Wechselrichter {number}", + "maximumChargingCurrentPerBattery": "Maximaler Ladestrom pro Batterie (A)", + "maximumDischargingCurrentPerBattery": "Maximaler Entladestrom pro Batterie (A)", + "powerPerInverterKW": "Leistung pro Wechselrichter (kW)", "startDateTime": "Startdatum und -zeit (Startzeit < Stoppzeit)", "stopDateTime": "Stoppdatum und -zeit (Startzeit < Stoppzeit)", "tourLanguageTitle": "Sprache", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 2a312466e..7e065d17d 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -234,6 +234,11 @@ "minimumSocPercent": "Minimum SoC (%)", "powerW": "Power (W)", "enterPowerValue": "Enter a positive or negative power value", + "inverterNumber": "Inverter Number", + "batteriesCountInInverter": "Batteries Count in Inverter {number}", + "maximumChargingCurrentPerBattery": "Maximum Charging Current per Battery (A)", + "maximumDischargingCurrentPerBattery": "Maximum Discharging Current per Battery (A)", + "powerPerInverterKW": "Power per Inverter (kW)", "startDateTime": "Start Date and Time (Start Time < Stop Time)", "stopDateTime": "Stop Date and Time (Start Time < Stop Time)", "tourLanguageTitle": "Language", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 3614a2847..7433cd7ed 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -486,6 +486,11 @@ "minimumSocPercent": "SoC minimum (%)", "powerW": "Puissance (W)", "enterPowerValue": "Entrez une valeur de puissance positive ou négative", + "inverterNumber": "Nombre d'onduleurs", + "batteriesCountInInverter": "Nombre de batteries dans l'onduleur {number}", + "maximumChargingCurrentPerBattery": "Courant de charge maximum par batterie (A)", + "maximumDischargingCurrentPerBattery": "Courant de décharge maximum par batterie (A)", + "powerPerInverterKW": "Puissance par onduleur (kW)", "startDateTime": "Date et heure de début (Début < Fin)", "stopDateTime": "Date et heure de fin (Début < Fin)", "tourLanguageTitle": "Langue", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index c1bc2f11a..5606a3f44 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -486,6 +486,11 @@ "minimumSocPercent": "SoC minimo (%)", "powerW": "Potenza (W)", "enterPowerValue": "Inserire un valore di potenza positivo o negativo", + "inverterNumber": "Numero di inverter", + "batteriesCountInInverter": "Numero di batterie nell'inverter {number}", + "maximumChargingCurrentPerBattery": "Corrente massima di carica per batteria (A)", + "maximumDischargingCurrentPerBattery": "Corrente massima di scarica per batteria (A)", + "powerPerInverterKW": "Potenza per inverter (kW)", "startDateTime": "Data e ora di inizio (Inizio < Fine)", "stopDateTime": "Data e ora di fine (Inizio < Fine)", "tourLanguageTitle": "Lingua", From f1cd5c07367212c8877689f0491ebd62e9809076 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 24 Mar 2026 12:12:34 +0100 Subject: [PATCH 2/6] scribe data from Information tab for configuration tab --- .../src/content/dashboards/Log/graph.util.tsx | 1 + .../SodistoreHomeConfiguration.tsx | 169 +++++++++--------- typescript/frontend-marios2/src/lang/de.json | 5 + typescript/frontend-marios2/src/lang/en.json | 5 + typescript/frontend-marios2/src/lang/fr.json | 5 + typescript/frontend-marios2/src/lang/it.json | 5 + 6 files changed, 101 insertions(+), 89 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx index e74ca9df3..ff7f7badf 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx @@ -702,6 +702,7 @@ export type ConfigurationValues = { batteriesCountPerInverter: number[]; clusterNumber: number; PvNumber: number; + pvCountPerInverter: number[]; controlPermission:boolean; // For sodistoreHome-Sinexcel: TimeChargeDischarge mode diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx index 2eee428e3..29e61be00 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx @@ -5,6 +5,7 @@ import { CardContent, CircularProgress, Container, + Divider, FormControl, Grid, IconButton, @@ -25,6 +26,7 @@ import axiosConfig from '../../../Resources/axiosConfig'; import { UserContext } from '../../../contexts/userContext'; import { ProductIdContext } from '../../../contexts/ProductIdContextProvider'; import { I_Installation } from 'src/interfaces/InstallationTypes'; +import { INSTALLATION_PRESETS } from '../Information/installationSetupUtils'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import {DateTimePicker } from '@mui/x-date-pickers'; @@ -106,6 +108,8 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { 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; @@ -251,6 +255,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { batteriesCount: formValues.batteriesCountPerInverter?.[0] ?? formValues.batteriesCount, clusterNumber:formValues.clusterNumber, PvNumber:formValues.PvNumber, + pvCountPerInverter: formValues.pvCountPerInverter, timeChargeandDischargePower: formValues.timeChargeandDischargePower, startTimeChargeandDischargeDayandTime: formValues.startTimeChargeandDischargeDayandTime ? new Date(formValues.startTimeChargeandDischargeDayandTime.getTime() - formValues.startTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000) @@ -416,7 +421,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { sx={{ transform: "scale(1.4)", marginLeft: "15px" }} /> } - + sx={{ ml: 0 }} label={
-
- { - setFormDirty(true); - const raw = e.target.value; - if (raw === '') { - setFormValues((prev) => ({ ...prev, inverterNumber: '' as any })); - return; - } - const parsed = parseInt(raw); - if (isNaN(parsed) || parsed < 1) return; - const currentArr = formValues.batteriesCountPerInverter || [1]; - const newArr = Array.from({ length: parsed }, (_, i) => currentArr[i] ?? 1); - setFormValues((prev) => ({ - ...prev, - inverterNumber: parsed, - batteriesCountPerInverter: newArr, - })); - }} - fullWidth - /> -
- - {Array.from({ length: formValues.inverterNumber ?? 1 }, (_, i) => ( -
- { - setFormDirty(true); - const raw = e.target.value; - if (raw === '') { - setFormValues((prev) => { - const arr = [...(prev.batteriesCountPerInverter || [1])]; - arr[i] = '' as any; - return { ...prev, batteriesCountPerInverter: arr }; - }); - return; - } - const parsed = parseInt(raw); - if (isNaN(parsed) || parsed < 1) return; - setFormValues((prev) => { - const arr = [...(prev.batteriesCountPerInverter || [1])]; - arr[i] = parsed; - return { ...prev, batteriesCountPerInverter: arr }; - }); - }} - fullWidth - /> -
- ))} - {device === 4 && ( <> -
- - } - name="clusterNumber" - value={formValues.clusterNumber} - onChange={handleChange} - fullWidth - /> -
- -
- - } - name="PvNumber" - value={formValues.PvNumber} - onChange={handleChange} - fullWidth - /> -
+ + + + )} + {device === 4 && (() => { + const preset = INSTALLATION_PRESETS[props.installation.installationModel]; + const inverterCount = preset ? preset.length : 1; + const pvStrings = (props.installation.pvStringsPerInverter || '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''); + + return ( + <> +
+ +
+ + {preset && preset.map((clusters, i) => { + const batteriesInInverter = clusters.reduce((a, b) => a + b, 0); + return ( +
+ +
+ ); + })} + + {Array.from({ length: inverterCount }, (_, i) => ( +
+ +
+ ))} + + ); + })()} + + {device === 4 && ( + <> + + + + + + )}
{/*
+ {device === 4 && ( + <> + + + + + + )} +
Date: Wed, 15 Apr 2026 19:13:27 +0200 Subject: [PATCH 3/6] new design with dynamic pricing --- csharp/App/Backend/DataTypes/Configuration.cs | 11 +- .../src/content/dashboards/Log/graph.util.tsx | 6 + .../SodistoreHomeConfiguration.tsx | 169 +++++++++++++++--- typescript/frontend-marios2/src/lang/de.json | 9 + typescript/frontend-marios2/src/lang/en.json | 9 + typescript/frontend-marios2/src/lang/fr.json | 9 + typescript/frontend-marios2/src/lang/it.json | 9 + 7 files changed, 196 insertions(+), 26 deletions(-) diff --git a/csharp/App/Backend/DataTypes/Configuration.cs b/csharp/App/Backend/DataTypes/Configuration.cs index 8ba1d7a43..a0e747a00 100644 --- a/csharp/App/Backend/DataTypes/Configuration.cs +++ b/csharp/App/Backend/DataTypes/Configuration.cs @@ -21,7 +21,13 @@ public class Configuration public double? TimeChargeandDischargePower { get; set; } public DateTime? StartTimeChargeandDischargeDayandTime { get; set; } public DateTime? StopTimeChargeandDischargeDayandTime { get; set; } - + + // Sinexcel Dynamic Pricing (under GridPriority) — strings for demo; engine will parse later. + public string? DynamicPricingMode { get; set; } + public string? CurrentPrice { get; set; } + public string? PriceToSell { get; set; } + public string? PriceToBuy { get; set; } + public String GetConfigurationString() { return $"MinimumSoC: {MinimumSoC}, GridSetPoint: {GridSetPoint}, CalibrationChargeState: {CalibrationChargeState}, CalibrationChargeDate: {CalibrationChargeDate}, " + @@ -48,7 +54,8 @@ public class Configuration { 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}"; + $"SinexcelTimeChargeandDischargePower: {TimeChargeandDischargePower}, SinexcelStartTimeChargeandDischargeDayandTime: {StartTimeChargeandDischargeDayandTime}, SinexcelStopTimeChargeandDischargeDayandTime: {StopTimeChargeandDischargeDayandTime}, " + + $"DynamicPricingMode: {DynamicPricingMode}, CurrentPrice: {CurrentPrice}, PriceToSell: {PriceToSell}, PriceToBuy: {PriceToBuy}"; } // TODO: SodistoreGrid — update configuration fields when defined diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx index 006cdd59a..f29b37388 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx @@ -709,6 +709,12 @@ export type ConfigurationValues = { timeChargeandDischargePower?: number; startTimeChargeandDischargeDayandTime?: Date | null; stopTimeChargeandDischargeDayandTime?: Date | null; + + // For sodistoreHome-Sinexcel: Dynamic Pricing (under GridPriority) + dynamicPricingMode?: string; + currentPrice?: string; + priceToSell?: string; + priceToBuy?: string; }; // // export interface Pv { diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx index b6576b083..14b88be79 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx @@ -9,6 +9,7 @@ import { FormControl, Grid, IconButton, + InputAdornment, InputLabel, Modal, Select, @@ -26,7 +27,12 @@ import axiosConfig from '../../../Resources/axiosConfig'; import { UserContext } from '../../../contexts/userContext'; import { ProductIdContext } from '../../../contexts/ProductIdContextProvider'; import { I_Installation } from 'src/interfaces/InstallationTypes'; -import { INSTALLATION_PRESETS } from '../Information/installationSetupUtils'; +import { + INSTALLATION_PRESETS, + buildSodistoreProPreset, + getPresetsForDevice, + PresetConfig +} from '../Information/installationSetupUtils'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import {DateTimePicker } from '@mui/x-date-pickers'; @@ -60,6 +66,14 @@ 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 = { + Disabled: 'dynamicPricingOff', + SpotPrice: 'dynamicPricingOn', + Tou: 'dynamicPricingTou', + }; + const [errors, setErrors] = useState({ minimumSoC: false, gridSetPoint: false @@ -122,6 +136,10 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { 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() ?? '', }; }; @@ -263,7 +281,11 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { stopTimeChargeandDischargeDayandTime: formValues.stopTimeChargeandDischargeDayandTime ? new Date(formValues.stopTimeChargeandDischargeDayandTime.getTime() - formValues.stopTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000) : null, - controlPermission:formValues.controlPermission + controlPermission:formValues.controlPermission, + dynamicPricingMode: formValues.dynamicPricingMode, + currentPrice: formValues.currentPrice, + priceToSell: formValues.priceToSell, + priceToBuy: formValues.priceToBuy, }; setLoading(true); @@ -441,12 +463,15 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { )} {device === 4 && (() => { - const preset = INSTALLATION_PRESETS[props.installation.installationModel]; - const inverterCount = preset ? preset.length : 1; - const pvStrings = (props.installation.pvStringsPerInverter || '') - .split(',') - .map((s) => s.trim()) - .filter((s) => s !== ''); + // Mirror Information tab's preset derivation so both views always agree. + const isSodistorePro = product === 5; + const model = props.installation.installationModel; + const presetConfig: PresetConfig | null = isSodistorePro + ? (model && parseInt(model, 10) > 0 + ? buildSodistoreProPreset(parseInt(model, 10)) + : null) + : (getPresetsForDevice(device)[model] || null); + const inverterCount = presetConfig?.length ?? 1; return ( <> @@ -459,8 +484,10 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { />
- {preset && preset.map((clusters, i) => { - const batteriesInInverter = clusters.reduce((a, b) => a + b, 0); + {Array.from({ length: inverterCount }, (_, i) => { + const batteriesInInverter = presetConfig + ? (presetConfig[i] ?? []).reduce((a, b) => a + b, 0) + : 0; return (
); })} - - {Array.from({ length: inverterCount }, (_, i) => ( -
- -
- ))} ); })()} @@ -679,6 +692,114 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { )} + {/* --- Sinexcel + GridPriority: Dynamic Pricing --- */} + {device === 4 && + OperatingPriorityOptions[formValues.operatingPriority] === 'GridPriority' && ( + <> + + + + + +
+ + + + + + +
+ + {formValues.dynamicPricingMode === 'SpotPrice' && ( + <> +
+ +
+ +
+ { + const v = e.target.value; + if (v === '' || /^\d*\.?\d*$/.test(v)) { + setFormDirty(true); + setFormValues((prev) => ({ ...prev, currentPrice: v })); + } + }} + InputProps={{ + endAdornment: CHF/kWh, + }} + fullWidth + /> +
+ +
+ { + const v = e.target.value; + if (v === '' || /^\d*\.?\d*$/.test(v)) { + setFormDirty(true); + setFormValues((prev) => ({ ...prev, priceToSell: v })); + } + }} + InputProps={{ + endAdornment: CHF/kWh, + }} + fullWidth + /> +
+ +
+ { + const v = e.target.value; + if (v === '' || /^\d*\.?\d*$/.test(v)) { + setFormDirty(true); + setFormValues((prev) => ({ ...prev, priceToBuy: v })); + } + }} + InputProps={{ + endAdornment: CHF/kWh, + }} + fullWidth + /> +
+ + )} + + )} +
Date: Thu, 16 Apr 2026 10:12:48 +0200 Subject: [PATCH 4/6] TOU --- csharp/App/Backend/Controller.cs | 10 ++- csharp/App/Backend/DataTypes/Configuration.cs | 9 ++- .../DataTypes/Configuration.cs | 11 +++ csharp/App/SinexcelCommunication/Program.cs | 9 +++ .../SystemConfig/Config.cs | 21 ++++-- .../src/content/dashboards/Log/graph.util.tsx | 5 ++ .../SodistoreHomeConfiguration.tsx | 70 +++++++++++++++++-- typescript/frontend-marios2/src/lang/de.json | 6 +- typescript/frontend-marios2/src/lang/en.json | 6 +- typescript/frontend-marios2/src/lang/fr.json | 6 +- typescript/frontend-marios2/src/lang/it.json | 6 +- 11 files changed, 143 insertions(+), 16 deletions(-) diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index ab756e82f..82da105b9 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -1894,7 +1894,15 @@ public class Controller : ControllerBase public async Task>> EditInstallationConfig([FromBody] Configuration config, Int64 installationId,int product,Token authToken) { var session = Db.GetSession(authToken); - + + // Dynamic Pricing in Spot Price mode: forward the provider chosen on the Information tab + // so the device knows which operator's API to query for spot prices. + if (config.DynamicPricingMode == "SpotPrice") + { + var installation = Db.GetInstallationById(installationId); + config.NetworkProvider = installation?.NetworkProvider; + } + string configString = product switch { 0 => config.GetConfigurationSalimax(), // Salimax diff --git a/csharp/App/Backend/DataTypes/Configuration.cs b/csharp/App/Backend/DataTypes/Configuration.cs index a0e747a00..4c17a392a 100644 --- a/csharp/App/Backend/DataTypes/Configuration.cs +++ b/csharp/App/Backend/DataTypes/Configuration.cs @@ -24,9 +24,15 @@ public class Configuration // Sinexcel Dynamic Pricing (under GridPriority) — strings for demo; engine will parse later. public string? DynamicPricingMode { get; set; } + public string? NetworkProvider { get; set; } public string? CurrentPrice { get; set; } public string? PriceToSell { get; set; } public string? PriceToBuy { get; set; } + // TOU windows stored as "HH:mm" strings + public string? TimeToSellFrom { get; set; } + public string? TimeToSellTo { get; set; } + public string? TimeToBuyFrom { get; set; } + public string? TimeToBuyTo { get; set; } public String GetConfigurationString() { @@ -55,7 +61,8 @@ public class Configuration 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}, CurrentPrice: {CurrentPrice}, PriceToSell: {PriceToSell}, PriceToBuy: {PriceToBuy}"; + $"DynamicPricingMode: {DynamicPricingMode}, NetworkProvider: {NetworkProvider}, CurrentPrice: {CurrentPrice}, PriceToSell: {PriceToSell}, PriceToBuy: {PriceToBuy}, " + + $"TimeToSell: {TimeToSellFrom}-{TimeToSellTo}, TimeToBuy: {TimeToBuyFrom}-{TimeToBuyTo}"; } // TODO: SodistoreGrid — update configuration fields when defined diff --git a/csharp/App/SinexcelCommunication/DataTypes/Configuration.cs b/csharp/App/SinexcelCommunication/DataTypes/Configuration.cs index 710cdcbd8..f75c5af4b 100644 --- a/csharp/App/SinexcelCommunication/DataTypes/Configuration.cs +++ b/csharp/App/SinexcelCommunication/DataTypes/Configuration.cs @@ -16,5 +16,16 @@ public class Configuration public Single TimeChargeandDischargePower { get; set; } public Boolean ControlPermission { get; set; } + // Dynamic Pricing (under GridPriority) — strings for demo; engine parses when needed. + public String? DynamicPricingMode { get; set; } + public String? NetworkProvider { get; set; } + public String? CurrentPrice { get; set; } + public String? PriceToSell { get; set; } + public String? PriceToBuy { get; set; } + public String? TimeToSellFrom { get; set; } + public String? TimeToSellTo { get; set; } + public String? TimeToBuyFrom { get; set; } + public String? TimeToBuyTo { get; set; } + } diff --git a/csharp/App/SinexcelCommunication/Program.cs b/csharp/App/SinexcelCommunication/Program.cs index ffbccabad..3466f76db 100644 --- a/csharp/App/SinexcelCommunication/Program.cs +++ b/csharp/App/SinexcelCommunication/Program.cs @@ -639,6 +639,15 @@ internal static class Program status.Config.PvNumber = config.PvNumber; status.Config.ControlPermission = config.ControlPermission; + status.Config.DynamicPricingMode = config.DynamicPricingMode; + status.Config.NetworkProvider = config.NetworkProvider; + status.Config.CurrentPrice = config.CurrentPrice; + status.Config.PriceToSell = config.PriceToSell; + status.Config.PriceToBuy = config.PriceToBuy; + status.Config.TimeToSellFrom = config.TimeToSellFrom; + status.Config.TimeToSellTo = config.TimeToSellTo; + status.Config.TimeToBuyFrom = config.TimeToBuyFrom; + status.Config.TimeToBuyTo = config.TimeToBuyTo; } private static async Task SaveModbusTcpFile(StatusRecord status) diff --git a/csharp/App/SinexcelCommunication/SystemConfig/Config.cs b/csharp/App/SinexcelCommunication/SystemConfig/Config.cs index 5c6dfe708..84fda6050 100644 --- a/csharp/App/SinexcelCommunication/SystemConfig/Config.cs +++ b/csharp/App/SinexcelCommunication/SystemConfig/Config.cs @@ -22,10 +22,10 @@ public class Config //public required Decimal CheapPrice { get; set; } //public required Decimal HighPrice { get; set; } public required Double MinSoc { get; set; } - public required Double GridSetPoint { get; set; } - public required Double MaximumDischargingCurrent { get; set; } - public required Double MaximumChargingCurrent { get; set; } - public required OperatingPriority OperatingPriority { get; set; } + public required Double GridSetPoint { get; set; } + public required Double MaximumDischargingCurrent { get; set; } + public required Double MaximumChargingCurrent { get; set; } + public required OperatingPriority OperatingPriority { get; set; } public required Int16 BatteriesCount { get; set; } public required Int16 ClusterNumber { get; set; } public required Int16 PvNumber { get; set; } @@ -34,7 +34,18 @@ public class Config public required DateTime StopTimeChargeandDischargeDayandTime { get; set; } public required Single TimeChargeandDischargePower { get; set; } - public required Boolean ControlPermission { get; set; } + public required Boolean ControlPermission { get; set; } + + // Dynamic Pricing (under GridPriority) — strings for demo; engine parses when needed. + public String? DynamicPricingMode { get; set; } + public String? NetworkProvider { get; set; } + public String? CurrentPrice { get; set; } + public String? PriceToSell { get; set; } + public String? PriceToBuy { get; set; } + public String? TimeToSellFrom { get; set; } + public String? TimeToSellTo { get; set; } + public String? TimeToBuyFrom { get; set; } + public String? TimeToBuyTo { get; set; } public required S3Config? S3 { get; set; } diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx index f29b37388..677a9ee71 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx @@ -715,6 +715,11 @@ export type ConfigurationValues = { currentPrice?: string; priceToSell?: string; priceToBuy?: string; + // TOU time windows stored as "HH:mm" strings + timeToSellFrom?: string; + timeToSellTo?: string; + timeToBuyFrom?: string; + timeToBuyTo?: string; }; // // export interface Pv { diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx index 14b88be79..463c09ba5 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx @@ -28,14 +28,13 @@ import { UserContext } from '../../../contexts/userContext'; import { ProductIdContext } from '../../../contexts/ProductIdContextProvider'; import { I_Installation } from 'src/interfaces/InstallationTypes'; import { - INSTALLATION_PRESETS, buildSodistoreProPreset, getPresetsForDevice, PresetConfig } from '../Information/installationSetupUtils'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -import {DateTimePicker } from '@mui/x-date-pickers'; +import { DateTimePicker, TimePicker } from '@mui/x-date-pickers'; import dayjs from 'dayjs'; import Switch from '@mui/material/Switch'; import FormControlLabel from '@mui/material/FormControlLabel'; @@ -70,7 +69,7 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { const DynamicPricingOptions = ['Disabled', 'SpotPrice', 'Tou'] as const; const dynamicPricingLabelKey: Record = { Disabled: 'dynamicPricingOff', - SpotPrice: 'dynamicPricingOn', + SpotPrice: 'dynamicPricingSpotPrice', Tou: 'dynamicPricingTou', }; @@ -140,6 +139,10 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { 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 ?? '', }; }; @@ -286,6 +289,10 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { currentPrice: formValues.currentPrice, priceToSell: formValues.priceToSell, priceToBuy: formValues.priceToBuy, + timeToSellFrom: formValues.timeToSellFrom, + timeToSellTo: formValues.timeToSellTo, + timeToBuyFrom: formValues.timeToBuyFrom, + timeToBuyTo: formValues.timeToBuyTo, }; setLoading(true); @@ -692,9 +699,9 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { )} - {/* --- Sinexcel + GridPriority: Dynamic Pricing --- */} + {/* --- Sinexcel + LoadPriority: Dynamic Pricing --- */} {device === 4 && - OperatingPriorityOptions[formValues.operatingPriority] === 'GridPriority' && ( + OperatingPriorityOptions[formValues.operatingPriority] === 'LoadPriority' && ( <> @@ -725,6 +732,59 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
+ {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 ( + + { + setFormDirty(true); + setFormValues((prev) => ({ + ...prev, + [key]: newValue ? newValue.format('HH:mm') : '', + })); + }} + renderInput={(params) => ( + + )} + /> + + ); + }; + + return ( + <> + + + +
+ {renderTimeField('timeFrom', 'timeToSellFrom')} + {renderTimeField('timeTo', 'timeToSellTo')} +
+ + + + +
+ {renderTimeField('timeFrom', 'timeToBuyFrom')} + {renderTimeField('timeTo', 'timeToBuyTo')} +
+ + ); + })()} + + )} + {formValues.dynamicPricingMode === 'SpotPrice' && ( <>
diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index a9689066b..d97f43373 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -532,11 +532,15 @@ "dynamicPricing": "Dynamische Preisgestaltung", "dynamicPricingMode": "Modus der dynamischen Preisgestaltung", "dynamicPricingOff": "Aus", - "dynamicPricingOn": "Ein", + "dynamicPricingSpotPrice": "Spot-Preis", "dynamicPricingTou": "TOU", "currentPrice": "Aktueller Preis", "priceToSell": "Verkaufspreis", "priceToBuy": "Kaufpreis", + "timeToSell": "Verkaufszeit", + "timeToBuy": "Kaufzeit", + "timeFrom": "Von", + "timeTo": "Bis", "networkProviderSetOnInformationTab": "Im Informations-Tab festlegen", "tourLanguageTitle": "Sprache", "tourLanguageContent": "Wählen Sie Ihre bevorzugte Sprache. Die Oberfläche unterstützt Englisch, Deutsch, Französisch und Italienisch.", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 834b50004..94e1f46e5 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -280,11 +280,15 @@ "dynamicPricing": "Dynamic Pricing", "dynamicPricingMode": "Dynamic Pricing Mode", "dynamicPricingOff": "Off", - "dynamicPricingOn": "On", + "dynamicPricingSpotPrice": "Spot Price", "dynamicPricingTou": "TOU", "currentPrice": "Current Price", "priceToSell": "Price to Sell", "priceToBuy": "Price to Buy", + "timeToSell": "Time to Sell", + "timeToBuy": "Time to Buy", + "timeFrom": "From", + "timeTo": "To", "networkProviderSetOnInformationTab": "Set on Information tab", "tourLanguageTitle": "Language", "tourLanguageContent": "Choose your preferred language. The interface supports English, German, French, and Italian.", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 7cc7c60b7..03c010f40 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -532,11 +532,15 @@ "dynamicPricing": "Tarification dynamique", "dynamicPricingMode": "Mode de tarification dynamique", "dynamicPricingOff": "Désactivé", - "dynamicPricingOn": "Activé", + "dynamicPricingSpotPrice": "Prix spot", "dynamicPricingTou": "TOU", "currentPrice": "Prix actuel", "priceToSell": "Prix de vente", "priceToBuy": "Prix d'achat", + "timeToSell": "Heure de vente", + "timeToBuy": "Heure d'achat", + "timeFrom": "De", + "timeTo": "À", "networkProviderSetOnInformationTab": "À définir dans l'onglet Informations", "tourLanguageTitle": "Langue", "tourLanguageContent": "Choisissez votre langue préférée. L'interface est disponible en anglais, allemand, français et italien.", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 61c120d16..823d98e4e 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -532,11 +532,15 @@ "dynamicPricing": "Prezzi dinamici", "dynamicPricingMode": "Modalità prezzi dinamici", "dynamicPricingOff": "Off", - "dynamicPricingOn": "On", + "dynamicPricingSpotPrice": "Prezzo spot", "dynamicPricingTou": "TOU", "currentPrice": "Prezzo attuale", "priceToSell": "Prezzo di vendita", "priceToBuy": "Prezzo di acquisto", + "timeToSell": "Orario di vendita", + "timeToBuy": "Orario di acquisto", + "timeFrom": "Da", + "timeTo": "A", "networkProviderSetOnInformationTab": "Imposta nella scheda Informazioni", "tourLanguageTitle": "Lingua", "tourLanguageContent": "Scegli la tua lingua preferita. L'interfaccia supporta inglese, tedesco, francese e italiano.", From be8c7d69b2097794b1580af5d445d59801b8464c Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 29 Apr 2026 09:34:35 +0200 Subject: [PATCH 5/6] new updates again --- csharp/App/Backend/DataTypes/Configuration.cs | 16 + .../src/content/dashboards/Log/graph.util.tsx | 14 + .../SodistoreHomeConfiguration.tsx | 342 +++++++++++++++--- typescript/frontend-marios2/src/lang/de.json | 5 + typescript/frontend-marios2/src/lang/en.json | 5 + typescript/frontend-marios2/src/lang/fr.json | 5 + typescript/frontend-marios2/src/lang/it.json | 5 + 7 files changed, 332 insertions(+), 60 deletions(-) diff --git a/csharp/App/Backend/DataTypes/Configuration.cs b/csharp/App/Backend/DataTypes/Configuration.cs index 4c17a392a..63a0fdb1e 100644 --- a/csharp/App/Backend/DataTypes/Configuration.cs +++ b/csharp/App/Backend/DataTypes/Configuration.cs @@ -11,6 +11,9 @@ public class Configuration 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? Inverters { get; set; } public double? OperatingPriority { get; set; } public int? InverterNumber { get; set; } public double? BatteriesCount { get; set; } @@ -77,4 +80,17 @@ public enum CalibrationChargeType RepetitivelyEvery, AdditionallyOnce, ChargePermanently +} + +public class InverterConfig +{ + public Dictionary Clusters { get; set; } = new(); + public int PvCount { get; set; } +} + +public class ClusterConfig +{ + public int BatteryCount { get; set; } + public double MaxChargingCurrent { get; set; } + public double MaxDischargingCurrent { get; set; } } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx index 677a9ee71..f4a0a49fc 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx @@ -685,6 +685,17 @@ export interface I_BoxDataValue { value: string | number; } +export type ClusterConfig = { + BatteryCount: number; + MaxChargingCurrent: number; + MaxDischargingCurrent: number; +}; + +export type InverterConfig = { + Clusters: { [clusterKey: string]: ClusterConfig }; + PvCount: number; +}; + export type ConfigurationValues = { minimumSoC: string | number; gridSetPoint: number; @@ -696,6 +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 }; operatingPriority: number; batteriesCount: number; inverterNumber: number; diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx index 463c09ba5..accd5fd16 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx @@ -1,8 +1,12 @@ -import { ConfigurationValues, JSONRecordData } from '../Log/graph.util'; +import { ConfigurationValues, InverterConfig, JSONRecordData } from '../Log/graph.util'; import { + Accordion, + AccordionDetails, + AccordionSummary, Alert, Box, CardContent, + Chip, CircularProgress, Container, Divider, @@ -17,6 +21,7 @@ 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'; @@ -30,7 +35,9 @@ import { I_Installation } from 'src/interfaces/InstallationTypes'; import { buildSodistoreProPreset, getPresetsForDevice, - PresetConfig + parseBatterySnTree, + PresetConfig, + BatterySnTree } from '../Information/installationSetupUtils'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; @@ -104,15 +111,67 @@ 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 => { 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 ), @@ -179,6 +238,40 @@ 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>(() => { @@ -266,17 +359,23 @@ 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 = { minimumSoC: formValues.minimumSoC, - maximumDischargingCurrent: formValues.maximumDischargingCurrent, - maximumChargingCurrent: formValues.maximumChargingCurrent, + maximumDischargingCurrent: firstCluster?.MaxDischargingCurrent ?? formValues.maximumDischargingCurrent, + maximumChargingCurrent: firstCluster?.MaxChargingCurrent ?? formValues.maximumChargingCurrent, + inverters, operatingPriority: formValues.operatingPriority, - inverterNumber: formValues.inverterNumber, - batteriesCountPerInverter: formValues.batteriesCountPerInverter, - batteriesCount: formValues.batteriesCountPerInverter?.[0] ?? formValues.batteriesCount, - clusterNumber:formValues.clusterNumber, - PvNumber:formValues.PvNumber, - pvCountPerInverter: formValues.pvCountPerInverter, timeChargeandDischargePower: formValues.timeChargeandDischargePower, startTimeChargeandDischargeDayandTime: formValues.startTimeChargeandDischargeDayandTime ? new Date(formValues.startTimeChargeandDischargeDayandTime.getTime() - formValues.startTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000) @@ -470,15 +569,15 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { )} {device === 4 && (() => { - // Mirror Information tab's preset derivation so both views always agree. - const isSodistorePro = product === 5; - const model = props.installation.installationModel; - const presetConfig: PresetConfig | null = isSodistorePro - ? (model && parseInt(model, 10) > 0 - ? buildSodistoreProPreset(parseInt(model, 10)) - : null) - : (getPresetsForDevice(device)[model] || null); - const inverterCount = presetConfig?.length ?? 1; + // 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 ( <> @@ -492,21 +591,70 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
{Array.from({ length: inverterCount }, (_, i) => { - const batteriesInInverter = presetConfig - ? (presetConfig[i] ?? []).reduce((a, b) => a + b, 0) - : 0; + 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 ( -
- -
+ + } + sx={{ + '& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' }, + justifyContent: 'flex-start', + '& .MuiAccordionSummary-expandIconWrapper': { ml: 1 } + }} + > + + + + + + + {clusters.map((_slotCount, clIdx) => { + const filledInCluster = (treeForInverter[clIdx] ?? []) + .filter((s) => s !== '').length; + return ( +
+ +
+ ); + })} + {(() => { + const pvCount = parseInt(pvStrings[i] || '0', 10) || 0; + return ( +
+ +
+ ); + })()} +
+
); })} @@ -556,25 +704,106 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
-
- -
+ {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 ( + + } + sx={{ + '& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' }, + justifyContent: 'flex-start', + '& .MuiAccordionSummary-expandIconWrapper': { ml: 1 } + }} + > + + + + + + {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 ( +
+ + + + setClusterField('MaxChargingCurrent', e.target.value)} + fullWidth + /> + setClusterField('MaxDischargingCurrent', e.target.value)} + fullWidth + /> +
+ ); + })} +
+
+ ); + }) + ) : ( + <> +
+ +
-
- -
+
+ +
+ + )} {device === 4 && ( <> @@ -802,16 +1031,9 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
{ - const v = e.target.value; - if (v === '' || /^\d*\.?\d*$/.test(v)) { - setFormDirty(true); - setFormValues((prev) => ({ ...prev, currentPrice: v })); - } - }} + value={(props.values.Config as any).CurrentPrice?.toString() ?? ''} InputProps={{ + readOnly: true, endAdornment: CHF/kWh, }} fullWidth diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 99c22feee..9522c3842 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -521,7 +521,12 @@ "enterPowerValue": "Positiven oder negativen Leistungswert eingeben", "inverterNumber": "Anzahl Wechselrichter", "batteriesCountInInverter": "Batterieanzahl in Wechselrichter {number}", + "batteryNumberInClusterN": "Batterieanzahl in Cluster {n}", + "pvStringsNumberInInverterN": "Anzahl PV-Strings in Wechselrichter {n}", + "batteries": "Batterien", "maximumChargingCurrentPerBattery": "Maximaler Ladestrom pro Batterie (A)", + "maximumChargingCurrentPerClusterLabel": "Maximaler Ladestrom pro Cluster (A)", + "maximumDischargingCurrentPerClusterLabel": "Maximaler Entladestrom pro Cluster (A)", "maximumDischargingCurrentPerBattery": "Maximaler Entladestrom pro Batterie (A)", "powerPerInverterKW": "Leistung pro Wechselrichter (kW)", "startDateTime": "Startdatum und -zeit (Startzeit < Stoppzeit)", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index f944eb822..8622a1357 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -269,8 +269,13 @@ "enterPowerValue": "Enter a positive or negative power value", "inverterNumber": "Inverter Number", "batteriesCountInInverter": "Batteries Count in Inverter {number}", + "batteryNumberInClusterN": "Battery Number in Cluster {n}", + "pvStringsNumberInInverterN": "PV Strings Number in Inverter {n}", + "batteries": "batteries", "maximumChargingCurrentPerBattery": "Maximum Charging Current per Battery (A)", "maximumDischargingCurrentPerBattery": "Maximum Discharging Current per Battery (A)", + "maximumChargingCurrentPerClusterLabel": "Maximum Charging Current per Cluster (A)", + "maximumDischargingCurrentPerClusterLabel": "Maximum Discharging Current per Cluster (A)", "powerPerInverterKW": "Power per Inverter (kW)", "startDateTime": "Start Date and Time (Start Time < Stop Time)", "stopDateTime": "Stop Date and Time (Start Time < Stop Time)", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 279e00d8a..6330f9206 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -521,7 +521,12 @@ "enterPowerValue": "Entrez une valeur de puissance positive ou négative", "inverterNumber": "Nombre d'onduleurs", "batteriesCountInInverter": "Nombre de batteries dans l'onduleur {number}", + "batteryNumberInClusterN": "Nombre de batteries dans le cluster {n}", + "pvStringsNumberInInverterN": "Nombre de chaînes PV dans l'onduleur {n}", + "batteries": "batteries", "maximumChargingCurrentPerBattery": "Courant de charge maximum par batterie (A)", + "maximumChargingCurrentPerClusterLabel": "Courant de charge maximum par cluster (A)", + "maximumDischargingCurrentPerClusterLabel": "Courant de décharge maximum par cluster (A)", "maximumDischargingCurrentPerBattery": "Courant de décharge maximum par batterie (A)", "powerPerInverterKW": "Puissance par onduleur (kW)", "startDateTime": "Date et heure de début (Début < Fin)", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index fea28ad13..0855d1158 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -521,7 +521,12 @@ "enterPowerValue": "Inserire un valore di potenza positivo o negativo", "inverterNumber": "Numero di inverter", "batteriesCountInInverter": "Numero di batterie nell'inverter {number}", + "batteryNumberInClusterN": "Numero di batterie nel cluster {n}", + "pvStringsNumberInInverterN": "Numero di stringhe PV nell'inverter {n}", + "batteries": "batterie", "maximumChargingCurrentPerBattery": "Corrente massima di carica per batteria (A)", + "maximumChargingCurrentPerClusterLabel": "Corrente massima di carica per cluster (A)", + "maximumDischargingCurrentPerClusterLabel": "Corrente massima di scarica per cluster (A)", "maximumDischargingCurrentPerBattery": "Corrente massima di scarica per batteria (A)", "powerPerInverterKW": "Potenza per inverter (kW)", "startDateTime": "Data e ora di inizio (Inizio < Fine)", From 2a258ae0e27f0355c544a7cecf414d05d00d6495 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Mon, 4 May 2026 17:51:34 +0200 Subject: [PATCH 6/6] Introduce a new Configuration page for prototype installations . All other installations keep the original one unchanged. --- csharp/App/Backend/Controller.cs | 13 +- csharp/App/Backend/DataTypes/Configuration.cs | 53 +- .../App/Backend/DataTypes/Methods/ExoCmd.cs | 12 +- .../src/content/dashboards/Log/graph.util.tsx | 6 +- .../SodiohomeInstallations/Installation.tsx | 23 +- .../SodistoreHomeConfiguration.tsx | 659 ++--------- .../SodistoreHomeConfigurationV2.tsx | 1026 +++++++++++++++++ 7 files changed, 1177 insertions(+), 615 deletions(-) create mode 100644 typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfigurationV2.tsx diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 90ee9d6a3..35105a84b 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -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); diff --git a/csharp/App/Backend/DataTypes/Configuration.cs b/csharp/App/Backend/DataTypes/Configuration.cs index 63a0fdb1e..589511bf4 100644 --- a/csharp/App/Backend/DataTypes/Configuration.cs +++ b/csharp/App/Backend/DataTypes/Configuration.cs @@ -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? Inverters { get; set; } - public double? OperatingPriority { get; set; } public int? InverterNumber { get; set; } public double? BatteriesCount { get; set; } public List? 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? 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 Clusters { get; set; } = new(); - public int PvCount { get; set; } + public Dictionary? Clusters { get; set; } + public int? PvCount { get; set; } } public class ClusterConfig diff --git a/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs b/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs index 44915ab37..133719cad 100644 --- a/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs +++ b/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs @@ -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(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(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); diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx index f4a0a49fc..4bae6392b 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/graph.util.tsx @@ -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; diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx index 272f6efcb..3fedde12d 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx @@ -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([790, 839]); import TopologySodistoreHome from '../Topology/TopologySodistoreHome'; import Overview from '../Overview/overview'; import WeeklyReport from './WeeklyReport'; @@ -599,11 +604,19 @@ function SodioHomeInstallation(props: singleInstallationProps) { + CONFIG_V2_INSTALLATION_IDS.has(props.current_installation.id) ? ( + + ) : ( + + ) } /> )} diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx index accd5fd16..aafbbd349 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx @@ -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 = { - 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 => { - 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 => ({ + 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>(() => { @@ -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 = { 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) { { - setFormDirty(true); + onChange={(e) => setFormValues((prev) => ({ ...prev, controlPermission: e.target.checked, - })); - } + })) } sx={{ transform: "scale(1.4)", marginLeft: "15px" }} /> } - sx={{ ml: 0 }} + label={
- {device === 4 && ( - <> - - - - - - )} - - {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 ( - <> -
- -
- - {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 ( - - } - sx={{ - '& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' }, - justifyContent: 'flex-start', - '& .MuiAccordionSummary-expandIconWrapper': { ml: 1 } - }} - > - - - - - - - {clusters.map((_slotCount, clIdx) => { - const filledInCluster = (treeForInverter[clIdx] ?? []) - .filter((s) => s !== '').length; - return ( -
- -
- ); - })} - {(() => { - const pvCount = parseInt(pvStrings[i] || '0', 10) || 0; - return ( -
- -
- ); - })()} -
-
- ); - })} - - ); - })()} +
+ + } + name="batteriesCount" + value={formValues.batteriesCount} + onChange={handleChange} + fullWidth + /> +
{device === 4 && ( <> - - - - +
+ + } + name="clusterNumber" + value={formValues.clusterNumber} + onChange={handleChange} + fullWidth + /> +
+ +
+ + } + name="PvNumber" + value={formValues.PvNumber} + onChange={handleChange} + fullWidth + /> +
)} +
{/* - {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 ( - - } - sx={{ - '& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' }, - justifyContent: 'flex-start', - '& .MuiAccordionSummary-expandIconWrapper': { ml: 1 } - }} - > - - - - - - {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 ( -
- - - - setClusterField('MaxChargingCurrent', e.target.value)} - fullWidth - /> - setClusterField('MaxDischargingCurrent', e.target.value)} - fullWidth - /> -
- ); - })} -
-
- ); - }) - ) : ( - <> -
- + -
+ } + name="maximumChargingCurrent" + value={formValues.maximumChargingCurrent} + onChange={handleChange} + fullWidth + /> +
-
- + -
- - )} - - {device === 4 && ( - <> - - - - - - )} + } + name="maximumDischargingCurrent" + value={formValues.maximumDischargingCurrent} + onChange={handleChange} + fullWidth + /> +
@@ -851,13 +558,13 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { {/* Power input*/}
handleTimeChargeDischargeChange(e.target.name, e.target.value) } - helperText={intl.formatMessage({ id: 'perInverter' })} + helperText={intl.formatMessage({ id: 'enterPowerValue' })} fullWidth />
@@ -928,160 +635,6 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { )} - {/* --- Sinexcel + LoadPriority: Dynamic Pricing --- */} - {device === 4 && - OperatingPriorityOptions[formValues.operatingPriority] === 'LoadPriority' && ( - <> - - - - - -
- - - - - - -
- - {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 ( - - { - setFormDirty(true); - setFormValues((prev) => ({ - ...prev, - [key]: newValue ? newValue.format('HH:mm') : '', - })); - }} - renderInput={(params) => ( - - )} - /> - - ); - }; - - return ( - <> - - - -
- {renderTimeField('timeFrom', 'timeToSellFrom')} - {renderTimeField('timeTo', 'timeToSellTo')} -
- - - - -
- {renderTimeField('timeFrom', 'timeToBuyFrom')} - {renderTimeField('timeTo', 'timeToBuyTo')} -
- - ); - })()} - - )} - - {formValues.dynamicPricingMode === 'SpotPrice' && ( - <> -
- -
- -
- CHF/kWh, - }} - fullWidth - /> -
- -
- { - const v = e.target.value; - if (v === '' || /^\d*\.?\d*$/.test(v)) { - setFormDirty(true); - setFormValues((prev) => ({ ...prev, priceToSell: v })); - } - }} - InputProps={{ - endAdornment: CHF/kWh, - }} - fullWidth - /> -
- -
- { - const v = e.target.value; - if (v === '' || /^\d*\.?\d*$/.test(v)) { - setFormDirty(true); - setFormValues((prev) => ({ ...prev, priceToBuy: v })); - } - }} - InputProps={{ - endAdornment: CHF/kWh, - }} - fullWidth - /> -
- - )} - - )} -
= { + 'SpontaneousSelfUse': 'LoadPriority', + 'TimeChargeDischarge': 'BatteryPriority', + 'PvPriorityCharging': 'GridPriority', + }; + + // Dynamic Pricing Mode — backend enum values with UI labels + const DynamicPricingOptions = ['Disabled', 'SpotPrice', 'Tou'] as const; + const dynamicPricingLabelKey: Record = { + Disabled: 'dynamicPricingOff', + SpotPrice: 'dynamicPricingSpotPrice', + Tou: 'dynamicPricingTou', + }; + + const [errors, setErrors] = useState({ + minimumSoC: false, + gridSetPoint: false + }); + + const SetErrorForField = (field_name, state) => { + setErrors((prevErrors) => ({ + ...prevErrors, + [field_name]: state + })); + }; + const theme = useTheme(); + const [formDirty, setFormDirty] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [updated, setUpdated] = useState(false); + const [dateSelectionError, setDateSelectionError] = useState(''); + const [isErrorDateModalOpen, setErrorDateModalOpen] = useState(false); + const context = useContext(UserContext); + const { currentUser, setUser } = context; + const { product, setProduct } = useContext(ProductIdContext); + + // Resolve S3 OperatingPriority to display index (Sinexcel uses different enum names) + const resolveOperatingPriorityIndex = (s3Value: string) => { + const displayName = device === 4 ? (sinexcelS3ToDisplayName[s3Value] ?? s3Value) : s3Value; + return OperatingPriorityOptions.indexOf(displayName); + }; + + // 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; + + // Default battery limit per cluster: 95A per battery for both charge and discharge + // (so 1-battery cluster → 95A, 2-battery cluster → 190A). + const DEFAULT_CURRENT_PER_BATTERY = 95; + const defaultClusterCurrent = (batteryCount: number) => batteryCount * DEFAULT_CURRENT_PER_BATTERY; + + // (No standalone preset-based builder — overlayTopology() handles the empty case + // and seeds defaults using the actual installed battery count from the Information tab, + // not the preset slot count.) + + // Helper to build form values from S3 data. + // V2 reads only fields it actually consumes — legacy flat keys (InverterNumber, + // BatteriesCount, MaximumChargingCurrent, etc.) are no longer wired through. + const getS3Values = (): Partial => { + // 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 cfgFromDisk: { [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 hasDevicesData = cfgFromDisk && Object.keys(cfgFromDisk).length > 0; + return { + minimumSoC: props.values.Config.MinSoc, + // Information tab is source of truth for topology + per-cluster current defaults. + devices: overlayTopology(hasDevicesData ? cfgFromDisk : undefined), + operatingPriority: resolveOperatingPriorityIndex( + props.values.Config.OperatingPriority + ), + 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 ?? '', + }; + }; + + // Restore pending config from localStorage, converting date strings back to Date objects. + // Returns { values, s3ConfigSnapshot } or null if no pending config. + const restorePendingConfig = () => { + try { + const pendingStr = localStorage.getItem(pendingConfigKey); + if (!pendingStr) return null; + + const pending = JSON.parse(pendingStr); + const v = pending.values; + const values: Partial = { + ...v, + // JSON.stringify converts Date→string; restore them back to Date objects + startTimeChargeandDischargeDayandTime: + v.startTimeChargeandDischargeDayandTime + ? dayjs(v.startTimeChargeandDischargeDayandTime).toDate() + : null, + stopTimeChargeandDischargeDayandTime: + v.stopTimeChargeandDischargeDayandTime + ? dayjs(v.stopTimeChargeandDischargeDayandTime).toDate() + : null, + }; + + return { values, s3ConfigSnapshot: pending.s3ConfigSnapshot || null }; + } catch (e) { + console.error('[Config:restore] Failed to parse localStorage', e); + localStorage.removeItem(pendingConfigKey); + return null; + } + }; + + // Fingerprint S3 Config for change detection (not value comparison) + const getS3ConfigFingerprint = () => JSON.stringify(props.values.Config); + + // Overlay Information-tab-derived topology onto a `devices` config object. + // Battery counts come from the SN tree (filled SNs); PvCount comes from pvStringsPerInverter. + // Existing per-cluster current limits are preserved. + const overlayTopology = ( + devices: { [k: string]: InverterConfig } | undefined + ): { [k: string]: InverterConfig } | undefined => { + if (!presetConfig) return devices; + 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 = devices?.[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]; + // Default current uses the cluster's ideal battery count (slotCount from the + // installation model preset), not the count of filled SNs — so an empty or + // partially-filled cluster still ships a sane non-zero limit. + const defaultCurrent = defaultClusterCurrent(slotCount); + cls[clKey] = { + BatteryCount: filled, + MaxChargingCurrent: existingCl?.MaxChargingCurrent ?? defaultCurrent, + MaxDischargingCurrent: existingCl?.MaxDischargingCurrent ?? defaultCurrent, + }; + }); + 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>(() => { + const pending = restorePendingConfig(); + const s3 = getS3Values(); + if (pending) { + // Check if S3 has new data since submit (fingerprint changed from snapshot) + const currentFingerprint = getS3ConfigFingerprint(); + const s3Changed = pending.s3ConfigSnapshot && currentFingerprint !== pending.s3ConfigSnapshot; + + if (s3Changed) { + // Device uploaded new data since our submit — trust S3 (device is authority) + localStorage.removeItem(pendingConfigKey); + return s3; + } + + // S3 still has same data as when we submitted — show pending values + return pending.values; + } + return s3; + }); + + // 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(); + + if (pending) { + const currentFingerprint = getS3ConfigFingerprint(); + const s3Changed = pending.s3ConfigSnapshot && currentFingerprint !== pending.s3ConfigSnapshot; + if (s3Changed) { + // S3 Config changed from snapshot → device uploaded new data → trust S3 + localStorage.removeItem(pendingConfigKey); + setFormValues(s3Values); + } else { + // S3 still has same data as at submit time — keep showing pending values + setFormValues(pending.values); + } + return; + } + + // No pending config — trust S3 (source of truth) + setFormValues(s3Values); + }, [props.values]); + + const handleOperatingPriorityChange = (event) => { + setFormDirty(true); + setFormValues({ + ...formValues, + ['operatingPriority']: OperatingPriorityOptions.indexOf( + event.target.value + ) + }); + }; + +// Add time validation function — only relevant for Sinexcel BatteryPriority + const validateTimeOnly = () => { + if (device === 4 && + OperatingPriorityOptions[formValues.operatingPriority] === 'BatteryPriority' && + formValues.startTimeChargeandDischargeDayandTime && + formValues.stopTimeChargeandDischargeDayandTime) { + const startHours = formValues.startTimeChargeandDischargeDayandTime.getHours(); + const startMinutes = formValues.startTimeChargeandDischargeDayandTime.getMinutes(); + const stopHours = formValues.stopTimeChargeandDischargeDayandTime.getHours(); + const stopMinutes = formValues.stopTimeChargeandDischargeDayandTime.getMinutes(); + + const startTimeInMinutes = startHours * 60 + startMinutes; + const stopTimeInMinutes = stopHours * 60 + stopMinutes; + + if (startTimeInMinutes >= stopTimeInMinutes) { + setDateSelectionError(intl.formatMessage({ id: 'stopTimeMustBeLater' })); + setErrorDateModalOpen(true); + return false; + } + } + return true; + }; + + const handleSubmit = async (e) => { + 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 devices = overlayTopology(formValues.devices); + const configurationToSend: Partial = { + minimumSoC: formValues.minimumSoC, + devices, + operatingPriority: formValues.operatingPriority, + timeChargeandDischargePower: formValues.timeChargeandDischargePower, + startTimeChargeandDischargeDayandTime: formValues.startTimeChargeandDischargeDayandTime + ? new Date(formValues.startTimeChargeandDischargeDayandTime.getTime() - formValues.startTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000) + : null, + 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, + }; + + setLoading(true); + const res = await axiosConfig + .post( + `/EditInstallationConfig?installationId=${props.id}&product=${product}`, + configurationToSend + ) + .catch((err) => { + if (err.response) { + setError(true); + setLoading(false); + } + }); + + 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. + // When S3 Config changes from this snapshot, the device has uploaded new data. + const cachePayload = { + values: formValues, + submittedAt: Date.now(), + s3ConfigSnapshot: getS3ConfigFingerprint(), + }; + localStorage.setItem(pendingConfigKey, JSON.stringify(cachePayload)); + } + }; + + const handleChange = (e) => { + setFormDirty(true); + const { name, value } = e.target; + + if (name === 'minimumSoC') { + const numValue = parseFloat(value); + + // invalid characters or not a number + if (/[^0-9.]/.test(value) || isNaN(numValue)) { + SetErrorForField(name, 'Invalid number format'); + } else { + const minsocRanges = { + 3: { min: 10, max: 30 }, + 4: { min: 5, max: 100 }, + }; + + const { min, max } = minsocRanges[device] || { min: 10, max: 30 }; + + if (numValue < min || numValue > max) { + SetErrorForField(name, `Value should be between ${min}-${max}%`); + } else { + // ✅ valid → clear error + SetErrorForField(name, ''); + } + } + } + + setFormValues(prev => ({ + ...prev, + [name]: value, + })); + }; + + const handleTimeChargeDischargeChange = (name: string, value: any) => { + setFormDirty(true); + setFormValues((prev) => ({ + ...prev, + [name]: value + })); + }; + + const handleOkOnErrorDateModal = () => { + setErrorDateModalOpen(false); + }; + + return ( + + + {isErrorDateModalOpen && ( + {}}> + + + {dateSelectionError} + + + + + + )} + + + + + <> +
+ { + setFormDirty(true); + setFormValues((prev) => ({ + ...prev, + controlPermission: e.target.checked, + })); + } + } + sx={{ transform: "scale(1.4)", marginLeft: "15px" }} + /> + } + sx={{ ml: 0 }} + label={ + + } + /> +
+ + {(device === 3 || device === 4) && ( + <> + + + + + + )} + +
+ {/**/} + {/* }*/} + {/* name="minimumSoC"*/} + {/* value={formValues.minimumSoC}*/} + {/* onChange={handleChange}*/} + {/* helperText={*/} + {/* errors.minimumSoC ? (*/} + {/* */} + {/* Value should be between {device === 4 ? '5–100' : '10–30'}%*/} + {/* */} + {/* ) : (*/} + {/* ''*/} + {/* )*/} + {/* }*/} + {/* fullWidth*/} + {/*/>*/} + + +
+ + {(device === 3 || 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 ( + + } + sx={{ + '& .MuiAccordionSummary-content': { flexGrow: 0, justifyContent: 'flex-start' }, + justifyContent: 'flex-start', + '& .MuiAccordionSummary-expandIconWrapper': { ml: 1 } + }} + > + + + + + + {clusters.map((_slotCount, clIdx) => { + const clKey = `Cluster${clIdx + 1}`; + const cluster = formValues.devices?.[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 devices = { ...(prev.devices ?? {}) }; + const inv = devices[invKey] + ?? { Clusters: {}, PvCount: 0 }; + const cls = { ...(inv.Clusters ?? {}) }; + const existing = cls[clKey] ?? { + BatteryCount: presetConfig?.[invIdx]?.[clIdx] ?? 0, + MaxChargingCurrent: 0, + MaxDischargingCurrent: 0, + }; + if (v === '') { + // Drop the field while editing so the input renders empty, + // not "0". On submit, anything still missing falls back to 0. + const next: any = { ...existing }; + delete next[field]; + cls[clKey] = next; + } else { + cls[clKey] = { ...existing, [field]: parseFloat(v) }; + } + devices[invKey] = { ...inv, Clusters: cls }; + return { ...prev, devices }; + }); + }; + return ( +
+ + + + setClusterField('MaxChargingCurrent', e.target.value)} + fullWidth + /> + setClusterField('MaxDischargingCurrent', e.target.value)} + fullWidth + /> +
+ ); + })} +
+
+ ); + }) + ) : ( + <> +
+ +
+ +
+ +
+ + )} + + {(device === 3 || device === 4) && ( + <> + + + + + + )} + +
+ + + + + + +
+ + + {/* --- Sinexcel + BatteryPriority (maps to TimeChargeDischarge on device) --- */} + {device === 4 && + OperatingPriorityOptions[formValues.operatingPriority] === + 'BatteryPriority' && ( + <> + {/* Power input*/} +
+ + handleTimeChargeDischargeChange(e.target.name, e.target.value) + } + helperText={intl.formatMessage({ id: 'perInverter' })} + fullWidth + /> +
+ + {/* Start DateTime */} +
+ + + setFormValues((prev) => ({ + ...prev, + startTimeChargeandDischargeDayandTime: newValue + ? newValue.toDate() + : null, + })) + } + renderInput={(params) => ( + + )} + /> + +
+ + {/* Stop DateTime */} +
+ + + setFormValues((prev) => ({ + ...prev, + stopTimeChargeandDischargeDayandTime: newValue + ? newValue.toDate() + : null, + })) + } + renderInput={(params) => ( + + )} + /> + +
+ + )} + + {/* --- Growatt + Sinexcel under LoadPriority: Dynamic Pricing --- */} + {(device === 3 || device === 4) && + OperatingPriorityOptions[formValues.operatingPriority] === 'LoadPriority' && ( + <> + + + + + +
+ + + + + + +
+ + {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 ( + + { + setFormDirty(true); + setFormValues((prev) => ({ + ...prev, + [key]: newValue ? newValue.format('HH:mm') : '', + })); + }} + renderInput={(params) => ( + + )} + /> + + ); + }; + + return ( + <> + + + +
+ {renderTimeField('timeFrom', 'timeToSellFrom')} + {renderTimeField('timeTo', 'timeToSellTo')} +
+ + + + +
+ {renderTimeField('timeFrom', 'timeToBuyFrom')} + {renderTimeField('timeTo', 'timeToBuyTo')} +
+ + ); + })()} + + )} + + {formValues.dynamicPricingMode === 'SpotPrice' && ( + <> +
+ +
+ +
+ CHF/kWh, + }} + fullWidth + /> +
+ +
+ { + const v = e.target.value; + if (v === '' || /^\d*\.?\d*$/.test(v)) { + setFormDirty(true); + setFormValues((prev) => ({ ...prev, priceToSell: v })); + } + }} + InputProps={{ + endAdornment: CHF/kWh, + }} + fullWidth + /> +
+ +
+ { + const v = e.target.value; + if (v === '' || /^\d*\.?\d*$/.test(v)) { + setFormDirty(true); + setFormValues((prev) => ({ ...prev, priceToBuy: v })); + } + }} + InputProps={{ + endAdornment: CHF/kWh, + }} + fullWidth + /> +
+ + )} + + )} + +
+ + {loading && ( + + )} + + {updated && ( + + + setUpdated(false)} + sx={{ marginLeft: '4px' }} + > + + + + )} + {error && ( + + + setError(false)} + sx={{ marginLeft: '4px' }} + > + + + + )} +
+
+
+
+
+
+ ); +} + +export default SodistoreHomeConfigurationV2;