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 { UserType } from '../../../interfaces/UserTypes';
import axiosConfig from '../../../Resources/axiosConfig'; import axiosConfig from '../../../Resources/axiosConfig';
import { import {
INSTALLATION_PRESETS, getPresetsForDevice,
PresetConfig, PresetConfig,
BatterySnTree, BatterySnTree,
parseBatterySnTree, parseBatterySnTree,
@ -113,7 +113,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
? (inverterCount && parseInt(inverterCount, 10) > 0 ? (inverterCount && parseInt(inverterCount, 10) > 0
? buildSodistoreProPreset(parseInt(inverterCount, 10)) ? buildSodistoreProPreset(parseInt(inverterCount, 10))
: null) : null)
: (INSTALLATION_PRESETS[selectedPreset] || null); : (getPresetsForDevice(formValues.device)[selectedPreset] || null);
const [batterySnTree, setBatterySnTree] = useState<BatterySnTree>(() => { const [batterySnTree, setBatterySnTree] = useState<BatterySnTree>(() => {
if (presetConfig) { if (presetConfig) {
@ -141,8 +141,32 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
return Array.from({ length: invCount }, () => '1'); 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 handlePresetChange = (newPreset: string) => {
const newConfig = INSTALLATION_PRESETS[newPreset]; const newConfig = getPresetsForDevice(formValues.device)[newPreset];
if (!newConfig) return; if (!newConfig) return;
// Check for data loss — either from existing tree or legacy flat data // 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 applyPreset = (newPreset: string) => {
const newConfig = INSTALLATION_PRESETS[newPreset]; const newConfig = getPresetsForDevice(formValues.device)[newPreset];
if (!newConfig) return; if (!newConfig) return;
setSelectedPreset(newPreset); setSelectedPreset(newPreset);
@ -315,10 +339,28 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
const handleChange = (e) => { const handleChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
setFormValues({ const updated = { ...formValues, [name]: value };
...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 = () => { const handleSubmit = () => {
@ -849,7 +891,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
<MenuItem value="" disabled> <MenuItem value="" disabled>
<em><FormattedMessage id="selectModel" defaultMessage="Select model..." /></em> <em><FormattedMessage id="selectModel" defaultMessage="Select model..." /></em>
</MenuItem> </MenuItem>
{Object.keys(INSTALLATION_PRESETS).map((name) => ( {Object.keys(getPresetsForDevice(formValues.device)).map((name) => (
<MenuItem key={name} value={name}> <MenuItem key={name} value={name}>
{name} {name}
</MenuItem> </MenuItem>

View File

@ -12,13 +12,24 @@ export type PresetConfig = number[][];
// 3D array: [inverter][cluster][batteryIndex] = serialNumber // 3D array: [inverter][cluster][batteryIndex] = serialNumber
export type BatterySnTree = string[][][]; export type BatterySnTree = string[][][];
export const INSTALLATION_PRESETS: Record<string, PresetConfig> = { // 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 9': [[1, 1]],
'sodistore home 18': [[2, 2]], 'sodistore home 18': [[2, 2]],
'sodistore home 27': [[2, 2], [1, 1]], 'sodistore home 27': [[2, 2], [1, 1]],
'sodistore home 36': [[2, 2], [2, 2]], 'sodistore home 36': [[2, 2], [2, 2]],
},
}; };
export const getPresetsForDevice = (deviceId: number): Record<string, PresetConfig> =>
INSTALLATION_PRESETS[deviceId] ?? {};
export const buildSodistoreProPreset = (inverterCount: number): PresetConfig => export const buildSodistoreProPreset = (inverterCount: number): PresetConfig =>
Array.from({ length: inverterCount }, () => [2, 2]); Array.from({ length: inverterCount }, () => [2, 2]);

View File

@ -6,7 +6,8 @@ export const getChartOptions = (
chartInfo: chartInfoInterface, chartInfo: chartInfoInterface,
type: string, type: string,
dateList: string[], dateList: string[],
stacked: Boolean stacked: Boolean,
voltageInfo?: chartInfoInterface
): ApexOptions => { ): ApexOptions => {
return type.includes('daily') return type.includes('daily')
? { ? {
@ -165,7 +166,28 @@ export const getChartOptions = (
return Math.round(value).toString(); 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, tickAmount: chartInfo.unit === '(%)' ? 5 : 6,

View File

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

View File

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

View File

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