adapt sodistore home setup for growatt and sinexcel, add battery voltage to OverView page

This commit is contained in:
Yinyin Liu 2026-04-08 10:06:40 +02:00
parent cac4b0e5f3
commit a8db23cadf
6 changed files with 169 additions and 67 deletions

View File

@ -34,7 +34,7 @@ import { useNavigate } from 'react-router-dom';
import { UserType } from '../../../interfaces/UserTypes';
import axiosConfig from '../../../Resources/axiosConfig';
import {
INSTALLATION_PRESETS,
getPresetsForDevice,
PresetConfig,
BatterySnTree,
parseBatterySnTree,
@ -113,7 +113,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
? (inverterCount && parseInt(inverterCount, 10) > 0
? buildSodistoreProPreset(parseInt(inverterCount, 10))
: null)
: (INSTALLATION_PRESETS[selectedPreset] || null);
: (getPresetsForDevice(formValues.device)[selectedPreset] || null);
const [batterySnTree, setBatterySnTree] = useState<BatterySnTree>(() => {
if (presetConfig) {
@ -141,8 +141,32 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
return Array.from({ length: invCount }, () => '1');
});
// When presetConfig is available, ensure flat values (batteryClusterNumber, batteryNumber)
// stay in sync with the current preset structure. This handles:
// - Legacy installations with device=0 → user sets device type
// - Preset structure changed (e.g., Growatt home 9 was [[1,1]] → now [[2]])
useEffect(() => {
if (!presetConfig) return;
// Re-parse battery tree if empty but serial numbers exist
let tree = batterySnTree;
if (tree.length === 0 && props.values.batterySerialNumbers) {
tree = parseBatterySnTree(props.values.batterySerialNumbers, presetConfig);
setBatterySnTree(tree);
}
// Always recalculate flat values from current preset to keep DB in sync
const flat = computeFlatValues(presetConfig, tree);
if (
flat.batteryClusterNumber !== formValues.batteryClusterNumber ||
flat.batteryNumber !== formValues.batteryNumber
) {
setFormValues((prev) => ({ ...prev, ...flat }));
}
}, [presetConfig]);
const handlePresetChange = (newPreset: string) => {
const newConfig = INSTALLATION_PRESETS[newPreset];
const newConfig = getPresetsForDevice(formValues.device)[newPreset];
if (!newConfig) return;
// Check for data loss — either from existing tree or legacy flat data
@ -171,7 +195,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
};
const applyPreset = (newPreset: string) => {
const newConfig = INSTALLATION_PRESETS[newPreset];
const newConfig = getPresetsForDevice(formValues.device)[newPreset];
if (!newConfig) return;
setSelectedPreset(newPreset);
@ -315,10 +339,28 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
const handleChange = (e) => {
const { name, value } = e.target;
setFormValues({
...formValues,
[name]: value
});
const updated = { ...formValues, [name]: value };
// When device type changes, reset preset if it's not available for the new device
if (name === 'device' && !isSodistorePro) {
const newDevicePresets = getPresetsForDevice(Number(value));
if (selectedPreset && !newDevicePresets[selectedPreset]) {
setSelectedPreset('');
setBatterySnTree([]);
setInverterSerialNumbers([]);
setDataloggerSerialNumbers([]);
setPvStringsPerInverter([]);
updated.installationModel = '';
updated.batteryNumber = 0;
updated.batteryClusterNumber = 0;
updated.batterySerialNumbers = '';
updated.inverterSN = '';
updated.dataloggerSN = '';
updated.pvStringsPerInverter = '';
}
}
setFormValues(updated);
};
const handleSubmit = () => {
@ -849,7 +891,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
<MenuItem value="" disabled>
<em><FormattedMessage id="selectModel" defaultMessage="Select model..." /></em>
</MenuItem>
{Object.keys(INSTALLATION_PRESETS).map((name) => (
{Object.keys(getPresetsForDevice(formValues.device)).map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>

View File

@ -12,13 +12,24 @@ export type PresetConfig = number[][];
// 3D array: [inverter][cluster][batteryIndex] = serialNumber
export type BatterySnTree = string[][][];
export const INSTALLATION_PRESETS: Record<string, PresetConfig> = {
'sodistore home 9': [[1, 1]],
'sodistore home 18': [[2, 2]],
'sodistore home 27': [[2, 2], [1, 1]],
'sodistore home 36': [[2, 2], [2, 2]],
// Device-aware presets: keyed by device ID, then model name
// Device 3 = Growatt, Device 4 = inesco 12K
export const INSTALLATION_PRESETS: Record<number, Record<string, PresetConfig>> = {
3: {
'sodistore home 9': [[2]],
'sodistore home 18': [[4]],
},
4: {
'sodistore home 9': [[1, 1]],
'sodistore home 18': [[2, 2]],
'sodistore home 27': [[2, 2], [1, 1]],
'sodistore home 36': [[2, 2], [2, 2]],
},
};
export const getPresetsForDevice = (deviceId: number): Record<string, PresetConfig> =>
INSTALLATION_PRESETS[deviceId] ?? {};
export const buildSodistoreProPreset = (inverterCount: number): PresetConfig =>
Array.from({ length: inverterCount }, () => [2, 2]);

View File

@ -6,7 +6,8 @@ export const getChartOptions = (
chartInfo: chartInfoInterface,
type: string,
dateList: string[],
stacked: Boolean
stacked: Boolean,
voltageInfo?: chartInfoInterface
): ApexOptions => {
return type.includes('daily')
? {
@ -165,7 +166,28 @@ export const getChartOptions = (
return Math.round(value).toString();
}
}
}
},
...(voltageInfo ? [{
seriesName: 'Battery Voltage',
opposite: true,
tickAmount: 5,
min: voltageInfo.min > 0 ? Math.floor(voltageInfo.min / 5) * 5 : 0,
max: Math.ceil(voltageInfo.max / 5) * 5,
title: {
text: '(V)',
style: {
fontSize: '12px'
},
offsetY: -190,
offsetX: -45,
rotate: 0
},
labels: {
formatter: function (value: number) {
return Math.round(value).toString();
}
}
}] : [])
]
: {
tickAmount: chartInfo.unit === '(%)' ? 5 : 6,

View File

@ -712,7 +712,8 @@ function Overview(props: OverviewProps) {
dailyDataArray[chartState].chartOverview.overview,
'dailyoverview',
[],
true
true,
(product === 2 || product === 5) ? dailyDataArray[chartState].chartOverview.batteryVoltage : undefined
),
chart: {
events: {
@ -744,7 +745,12 @@ function Overview(props: OverviewProps) {
...dailyDataArray[chartState].chartData.soc,
type: 'line',
color: '#008FFB'
}
},
...((product === 2 || product === 5) ? [{
...dailyDataArray[chartState].chartData.batteryVoltage,
type: 'line' as const,
color: '#9b59b6'
}] : [])
]}
height={420}
/>

View File

@ -17,7 +17,7 @@ import { Close as CloseIcon } from '@mui/icons-material';
import { I_Installation } from 'src/interfaces/InstallationTypes';
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
import { FormattedMessage } from 'react-intl';
import { INSTALLATION_PRESETS, SODIOHOME_DEVICE_TYPES } from '../Information/installationSetupUtils';
import { getPresetsForDevice, SODIOHOME_DEVICE_TYPES } from '../Information/installationSetupUtils';
interface SodistorehomeInstallationFormPros {
cancel: () => void;
@ -38,7 +38,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
...(isSodistorePro ? { device: 4 } : {}),
});
const [inverterCount, setInverterCount] = useState('');
const requiredFields = ['name', 'vpnIp', ...(isSodistorePro ? [] : ['installationModel'])];
const requiredFields = ['name', 'vpnIp', ...(isSodistorePro ? [] : ['device', 'installationModel'])];
const DeviceTypes = isSodistorePro
? [{ id: 4, name: 'inesco 12K - WR Hybrid' }]
@ -49,11 +49,17 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
const handleChange = (e) => {
const { name, value } = e.target;
const updated = { ...formValues, [name]: value };
setFormValues({
...formValues,
[name]: value
});
// Reset preset when device type changes if current preset is invalid
if (name === 'device' && !isSodistorePro) {
const newDevicePresets = getPresetsForDevice(Number(value));
if (formValues.installationModel && !newDevicePresets[formValues.installationModel]) {
updated.installationModel = '';
}
}
setFormValues(updated);
};
const handleSubmit = async (e) => {
setLoading(true);
@ -167,10 +173,48 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
/>
</div>
) : (
<>
{/* Device type must be selected before model — it determines available presets */}
<div>
<FormControl
fullWidth
required
sx={{
marginTop: 1,
marginBottom: 1,
width: 390
}}
>
<InputLabel
sx={{
fontSize: 14,
backgroundColor: 'white'
}}
>
<FormattedMessage
id="DeviceType"
defaultMessage="Device Type"
/>
</InputLabel>
<Select
name="device"
value={formValues.device ?? ''}
onChange={handleChange}
>
{DeviceTypes.map((device) => (
<MenuItem key={device.id} value={device.id}>
{device.name}
</MenuItem>
))}
</Select>
</FormControl>
</div>
<div>
<FormControl
fullWidth
required
disabled={!formValues.device}
error={formValues.installationModel === ''}
sx={{
marginTop: 1,
@ -194,7 +238,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
value={formValues.installationModel || ''}
onChange={handleChange}
>
{Object.keys(INSTALLATION_PRESETS).map((name) => (
{Object.keys(getPresetsForDevice(formValues.device as number)).map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
@ -202,42 +246,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
</Select>
</FormControl>
</div>
)}
{!isSodistorePro && (
<div>
<FormControl
fullWidth
sx={{
marginTop: 1,
marginBottom: 1,
width: 390
}}
>
<InputLabel
sx={{
fontSize: 14,
backgroundColor: 'white'
}}
>
<FormattedMessage
id="DeviceType"
defaultMessage="Device Type"
/>
</InputLabel>
<Select
name="device"
value={formValues.device}
onChange={handleChange}
>
{DeviceTypes.map((device) => (
<MenuItem key={device.id} value={device.id}>
{device.name}
</MenuItem>
))}
</Select>
</FormControl>
</div>
</>
)}
</Box>

View File

@ -30,6 +30,7 @@ export interface overviewInterface {
overview: chartInfoInterface;
ACLoad: chartInfoInterface;
DCLoad: chartInfoInterface;
batteryVoltage: chartInfoInterface;
}
export interface chartAggregatedDataInterface {
@ -53,6 +54,7 @@ export interface chartDataInterface {
dcBusVoltage: { name: string; data: number[] };
ACLoad: { name: string; data: number[] };
DCLoad: { name: string; data: number[] };
batteryVoltage: { name: string; data: number[] };
}
export interface BatteryDataInterface {
@ -428,7 +430,8 @@ export const transformInputToDailyDataJson = async (
'SODIOHOME_PV_POWER',
null, // dcBusVoltage not available for SodioHome
'SODIOHOME_CONSUMPTION',
null // DCLoad not available for SodioHome
null, // DCLoad not available for SodioHome
'SODIOHOME_BATTERY_VOLTAGE'
]
: [
'Battery.Soc',
@ -438,7 +441,8 @@ export const transformInputToDailyDataJson = async (
'PvOnDc',
'DcDc.Dc.Link.Voltage',
'LoadOnAcGrid.Power.Active',
'LoadOnDc.Power'
'LoadOnDc.Power',
null // batteryVoltage not available for Salimax
];
const categories = [
'soc',
@ -448,7 +452,8 @@ export const transformInputToDailyDataJson = async (
'pvProduction',
'dcBusVoltage',
'ACLoad',
'DCLoad'
'DCLoad',
'batteryVoltage'
];
const chartData: chartDataInterface = {
@ -459,7 +464,8 @@ export const transformInputToDailyDataJson = async (
pvProduction: { name: 'PV Power', data: [] },
dcBusVoltage: { name: 'DC Bus Voltage', data: [] },
ACLoad: { name: 'AC Load', data: [] },
DCLoad: { name: 'DC Load', data: [] }
DCLoad: { name: 'DC Load', data: [] },
batteryVoltage: { name: 'Battery Voltage', data: [] }
};
const chartOverview: overviewInterface = {
@ -472,7 +478,8 @@ export const transformInputToDailyDataJson = async (
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
overview: { magnitude: 0, unit: '', min: 0, max: 0 },
ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 }
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
batteryVoltage: { magnitude: 0, unit: '', min: 0, max: 0 }
};
categories.forEach((category) => {
@ -566,6 +573,9 @@ export const transformInputToDailyDataJson = async (
case 6: // consumption
value = inv.TotalLoadPower ?? inv.ConsumptionPower;
break;
case 8: // battery voltage
value = inv.AvgBatteryVoltage ?? inv.Battery1Voltage;
break;
}
}
} else if (category_index === 4) {
@ -636,6 +646,7 @@ export const transformInputToDailyDataJson = async (
'(' + prefixes[chartOverview['ACLoad'].magnitude] + 'W' + ')';
chartOverview.DCLoad.unit =
'(' + prefixes[chartOverview['DCLoad'].magnitude] + 'W' + ')';
chartOverview.batteryVoltage.unit = '(V)';
chartOverview.overview = {
magnitude: Math.max(
@ -750,7 +761,8 @@ export const transformInputToAggregatedDataJson = async (
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
overview: { magnitude: 0, unit: '', min: 0, max: 0 },
ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 }
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
batteryVoltage: { magnitude: 0, unit: '', min: 0, max: 0 }
};
pathsToSearch.forEach((path) => {