diff --git a/csharp/App/Backend/DataTypes/Configuration.cs b/csharp/App/Backend/DataTypes/Configuration.cs index 589511bf4..7acb4344b 100644 --- a/csharp/App/Backend/DataTypes/Configuration.cs +++ b/csharp/App/Backend/DataTypes/Configuration.cs @@ -28,6 +28,13 @@ public class Configuration public DateTime? StartTimeChargeandDischargeDayandTime { get; set; } public DateTime? StopTimeChargeandDischargeDayandTime { get; set; } + // SodistoreGrid: inverter battery-limit settings (Sinexcel) — surfaced on Configuration tab. + public double? ActivePowerPercent { get; set; } + public double? MinDischargeVoltage { get; set; } + public double? MaxDischargeCurrent { get; set; } + public double? MaxChargeCurrent { get; set; } + public double? MaxChargeVoltage { get; set; } + // Sinexcel Dynamic Pricing (under GridPriority) — strings for demo; engine will parse later. public string? DynamicPricingMode { get; set; } public string? NetworkProvider { get; set; } diff --git a/typescript/frontend-marios2/src/content/dashboards/Configuration/ConfigurationSodistoreGrid.tsx b/typescript/frontend-marios2/src/content/dashboards/Configuration/ConfigurationSodistoreGrid.tsx index 4d101e817..b3e9f141a 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Configuration/ConfigurationSodistoreGrid.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Configuration/ConfigurationSodistoreGrid.tsx @@ -1,4 +1,4 @@ -import { ConfigurationValues, JSONRecordData } from '../Log/graph.util'; +import { JSONRecordData } from '../Log/graph.util'; import { Alert, Box, @@ -22,94 +22,147 @@ interface ConfigurationSodistoreGridProps { id: number; } +// Wire-format keys (camelCase → backend Configuration PascalCase via case-insensitive bind). +interface GridFormValues { + minimumSoC?: string; + activePowerPercent?: string; + minDischargeVoltage?: string; + maxDischargeCurrent?: string; + maxChargeCurrent?: string; + maxChargeVoltage?: string; +} + +// Per-field validation rules. +// type="text" + regex (NOT type="number" — avoids spinner & locale issues, per project rule). +const FIELDS: { + name: keyof GridFormValues; + labelId: string; + min?: number; + max?: number; + allowNegative?: boolean; +}[] = [ + { name: 'minimumSoC', labelId: 'minimumSocPercent', min: 0, max: 100 }, + { name: 'activePowerPercent', labelId: 'activePowerPercent', min: -100, max: 100, allowNegative: true }, + { name: 'minDischargeVoltage', labelId: 'minDischargeVoltageV', min: 0 }, + { name: 'maxDischargeCurrent', labelId: 'maxDischargeCurrentA', min: 0 }, + { name: 'maxChargeCurrent', labelId: 'maxChargeCurrentA', min: 0 }, + { name: 'maxChargeVoltage', labelId: 'maxChargeVoltageV', min: 0 } +]; + function ConfigurationSodistoreGrid(props: ConfigurationSodistoreGridProps) { const intl = useIntl(); - if (props.values === null) { - return null; - } - const theme = useTheme(); + + // All hooks must be called unconditionally (Rules of Hooks). The null-check + // moved BELOW the hook declarations. const [loading, setLoading] = useState(false); const [error, setError] = useState(false); const [updated, setUpdated] = useState(false); - const [errors, setErrors] = useState({ + // Lazy initializer reads from props.values safely even when null. + const [formValues, setFormValues] = useState(() => { + const inv: any = props.values?.InverterRecord ?? {}; + const cfg: any = props.values?.Config ?? {}; + return { + minimumSoC: cfg.MinSoc != null ? String(cfg.MinSoc) : '', + activePowerPercent: cfg.ActivePowerPercent != null ? String(cfg.ActivePowerPercent) : '', + minDischargeVoltage: inv.MinDischargeVoltage != null ? String(inv.MinDischargeVoltage) : '', + maxDischargeCurrent: inv.MaxDischargeCurrent != null ? String(inv.MaxDischargeCurrent) : '', + maxChargeCurrent: inv.MaxChargeCurrent != null ? String(inv.MaxChargeCurrent) : '', + maxChargeVoltage: inv.MaxChargeVoltage != null ? String(inv.MaxChargeVoltage) : '' + }; + }); + + const [errors, setErrors] = useState>({ minimumSoC: false, - gridSetPoint: false + activePowerPercent: false, + minDischargeVoltage: false, + maxDischargeCurrent: false, + maxChargeCurrent: false, + maxChargeVoltage: false }); - const SetErrorForField = (field_name: string, state: boolean) => { - setErrors((prevErrors) => ({ - ...prevErrors, - [field_name]: state - })); + if (props.values === null) { + return null; + } + + const validate = ( + field: (typeof FIELDS)[number], + raw: string + ): boolean => { + if (raw.trim() === '') return false; // empty = "leave unchanged", not error + // Regex: allow leading minus only if field accepts negatives, optional digits + dot + const pattern = field.allowNegative ? /^-?\d*\.?\d*$/ : /^\d*\.?\d*$/; + if (!pattern.test(raw)) return true; + const n = parseFloat(raw); + if (isNaN(n)) return true; + if (field.min !== undefined && n < field.min) return true; + if (field.max !== undefined && n > field.max) return true; + return false; }; - const [formValues, setFormValues] = useState>({ - minimumSoC: props.values.Config?.MinSoc, - gridSetPoint: - props.values.Config?.GridSetPoint != null - ? (props.values.Config.GridSetPoint as number) / 1000 - : undefined - }); - - const handleChange = (e) => { + const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; + const field = FIELDS.find((f) => f.name === name); + if (!field) return; - switch (name) { - case 'minimumSoC': - if ( - /[^0-9.]/.test(value) || - isNaN(parseFloat(value)) || - parseFloat(value) > 100 - ) { - SetErrorForField(name, true); - } else { - SetErrorForField(name, false); - } - break; - case 'gridSetPoint': - if (/[^0-9.]/.test(value) || isNaN(parseFloat(value))) { - SetErrorForField(name, true); - } else { - SetErrorForField(name, false); - } - break; - default: - return true; - } - - setFormValues({ - ...formValues, - [name]: value - }); + setErrors((prev) => ({ ...prev, [name]: validate(field, value) })); + setFormValues((prev) => ({ ...prev, [name]: value })); }; + const anyError = Object.values(errors).some(Boolean); + const allEmpty = Object.values(formValues).every( + (v) => !v || v.trim() === '' + ); + const handleSubmit = async () => { - const configurationToSend: Partial = { - minimumSoC: formValues.minimumSoC, - gridSetPoint: formValues.gridSetPoint - }; + // Only send fields the user actually entered. Empty string = skip. + const payload: Record = {}; + FIELDS.forEach((f) => { + const raw = formValues[f.name]; + if (raw && raw.trim() !== '') { + const n = parseFloat(raw); + if (!isNaN(n)) payload[f.name] = n; + } + }); + + if (Object.keys(payload).length === 0) return; setLoading(true); - const res = await axiosConfig - .post( - `/EditInstallationConfig?installationId=${props.id}&product=4`, - configurationToSend - ) - .catch((err) => { - if (err.response) { - setError(true); - setLoading(false); - } - }); + setError(false); + setUpdated(false); - if (res) { + try { + await axiosConfig.post( + `/EditInstallationConfig?installationId=${props.id}&product=4`, + payload + ); setUpdated(true); + } catch { + setError(true); + } finally { setLoading(false); } }; + const helperFor = (name: keyof GridFormValues, hasError: boolean) => { + if (!hasError) return ''; + const f = FIELDS.find((fld) => fld.name === name)!; + // Range-aware helper: 0–100 → existing key; otherwise generic. + if (f.min === 0 && f.max === 100) { + return ( + + {intl.formatMessage({ id: 'valueBetween0And100' })} + + ); + } + return ( + + {intl.formatMessage({ id: 'pleaseProvideValidNumber' })} + + ); + }; + return ( { + // Prevent native form submit (Enter in TextField would navigate). + e.preventDefault(); + handleSubmit(); + }} > -
- - } - name="minimumSoC" - value={formValues.minimumSoC ?? ''} - onChange={handleChange} - helperText={ - errors.minimumSoC ? ( - - {intl.formatMessage({ id: 'valueBetween0And100' })} - - ) : ( - '' - ) - } - fullWidth - /> -
- -
- - } - name="gridSetPoint" - value={formValues.gridSetPoint ?? ''} - onChange={handleChange} - helperText={ - errors.gridSetPoint ? ( - - {intl.formatMessage({ id: 'pleaseProvideValidNumber' })} - - ) : ( - '' - ) - } - fullWidth - /> -
+ {FIELDS.map((f) => ( +
+ } + name={f.name} + value={formValues[f.name] ?? ''} + onChange={handleChange} + error={errors[f.name]} + helperText={helperFor(f.name, errors[f.name])} + fullWidth + /> +
+ ))}
diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx index 1e423f162..37bab1b99 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx @@ -54,6 +54,7 @@ function Installation(props: singleInstallationProps) { const status = props.current_installation.status; // For SodiStoreGrid (product 4), backend heartbeat path is broken (SinexcelCommunication // hardcodes Product=2), so installation.status stays -1 even when S3 is fresh. + // TODO: remove this override once SinexcelCommunication/Program.cs derives Product from runtime config. // Treat as Green when our S3 fetch succeeded (connected === true). const [connected, setConnected] = useState(true); const [loading, setLoading] = useState(true); diff --git a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx index 35f85c0f8..31d18b3bb 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx @@ -566,7 +566,12 @@ function Overview(props: OverviewProps) { > - {props.device !== 3 && props.product !== 2 && props.product !== 4 && ( + {/* Hide "Last week" for SodioHome (product 2) and SodiStoreGrid (product 4) — + neither has aggregated weekly data. Uses context `product` because + `props.product` isn't passed from Installation.tsx; `props.device` is also + never passed, so the legacy `device !== 3` (Growatt) check was a no-op — + Growatt installs all have product=2, so the product check covers them. */} + {product !== 2 && product !== 4 && (