Merge branch 'main' into integrated_AI

This commit is contained in:
Yinyin Liu 2026-02-12 07:45:22 +01:00
commit 2da2ded84c
10 changed files with 1275 additions and 249 deletions

View File

@ -195,9 +195,13 @@ public class Controller : ControllerBase
while (startTimestamp <= endTimestamp) while (startTimestamp <= endTimestamp)
{ {
string bucketPath = installation.Product==(int)ProductType.Salimax || installation.Product==(int)ProductType.SodiStoreMax? string bucketPath;
"s3://"+installation.S3BucketId + "-3e5b3069-214a-43ee-8d85-57d72000c19d/"+startTimestamp : if (installation.Product == (int)ProductType.Salimax || installation.Product == (int)ProductType.SodiStoreMax)
"s3://"+installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/"+startTimestamp; bucketPath = "s3://" + installation.S3BucketId + "-3e5b3069-214a-43ee-8d85-57d72000c19d/" + startTimestamp;
else if (installation.Product == (int)ProductType.SodioHome)
bucketPath = "s3://" + installation.S3BucketId + "-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa/" + startTimestamp;
else
bucketPath = "s3://" + installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/" + startTimestamp;
Console.WriteLine("Fetching data for "+startTimestamp); Console.WriteLine("Fetching data for "+startTimestamp);
try try

View File

@ -12,13 +12,14 @@ import {
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { JSONRecordData } from '../Log/graph.util'; import { JSONRecordData } from '../Log/graph.util';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { I_S3Credentials } from '../../../interfaces/S3Types'; import { I_S3Credentials } from '../../../interfaces/S3Types';
import routes from '../../../Resources/routes.json'; import routes from '../../../Resources/routes.json';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { I_Installation } from 'src/interfaces/InstallationTypes'; import { I_Installation } from 'src/interfaces/InstallationTypes';
import MainStatsSodioHome from './MainStatsSodioHome';
interface BatteryViewSodioHomeProps { interface BatteryViewSodioHomeProps {
values: JSONRecordData; values: JSONRecordData;
@ -164,54 +165,20 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
</Grid> </Grid>
</Grid> </Grid>
{/*<Grid container>*/} <Grid container>
{/* <Routes>*/} <Routes>
{/* <Route*/} <Route
{/* path={routes.mainstats + '/*'}*/} path={routes.mainstats + '/*'}
{/* element={*/} element={
{/* <MainStats*/} <MainStatsSodioHome
{/* s3Credentials={props.s3Credentials}*/} s3Credentials={props.s3Credentials}
{/* id={props.installationId}*/} id={props.installationId}
{/* ></MainStats>*/} batteryClusterNumber={props.installation.batteryClusterNumber}
{/* }*/} ></MainStatsSodioHome>
{/* />*/} }
{/* {product === 0*/} />
{/* ? Object.entries(props.values.Battery.Devices).map(*/} </Routes>
{/* ([BatteryId, battery]) => (*/} </Grid>
{/* <Route*/}
{/* key={routes.detailed_view + BatteryId}*/}
{/* path={routes.detailed_view + BatteryId}*/}
{/* element={*/}
{/* <DetailedBatteryView*/}
{/* batteryId={Number(BatteryId)}*/}
{/* s3Credentials={props.s3Credentials}*/}
{/* batteryData={battery}*/}
{/* installationId={props.installationId}*/}
{/* productNum={product}*/}
{/* ></DetailedBatteryView>*/}
{/* }*/}
{/* />*/}
{/* )*/}
{/* )*/}
{/* : Object.entries(props.values.Battery.Devices).map(*/}
{/* ([BatteryId, battery]) => (*/}
{/* <Route*/}
{/* key={routes.detailed_view + BatteryId}*/}
{/* path={routes.detailed_view + BatteryId}*/}
{/* element={*/}
{/* <DetailedBatteryViewSodistore*/}
{/* batteryId={Number(BatteryId)}*/}
{/* s3Credentials={props.s3Credentials}*/}
{/* batteryData={battery}*/}
{/* installationId={props.installationId}*/}
{/* productNum={product}*/}
{/* ></DetailedBatteryViewSodistore>*/}
{/* }*/}
{/* />*/}
{/* )*/}
{/* )}*/}
{/* </Routes>*/}
{/*</Grid>*/}
<TableContainer <TableContainer
component={Paper} component={Paper}

View File

@ -0,0 +1,818 @@
import {
Box,
Card,
Container,
Grid,
IconButton,
Modal,
TextField,
Typography
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
import React, { useEffect, useState } from 'react';
import { I_S3Credentials } from '../../../interfaces/S3Types';
import ReactApexChart from 'react-apexcharts';
import { getChartOptions } from '../Overview/chartOptions';
import {
BatteryDataInterface,
BatteryOverviewInterface,
transformInputToBatteryViewDataJson
} from '../../../interfaces/Chart';
import dayjs, { Dayjs } from 'dayjs';
import { TimeSpan, UnixTime } from '../../../dataCache/time';
import Button from '@mui/material/Button';
import { DateTimePicker, LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import CircularProgress from '@mui/material/CircularProgress';
import { useLocation, useNavigate } from 'react-router-dom';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
interface MainStatsSodioHomeProps {
s3Credentials: I_S3Credentials;
id: number;
batteryClusterNumber: number;
}
function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
const [chartState, setChartState] = useState(0);
const [batteryViewDataArray, setBatteryViewDataArray] = useState<
{
chartData: BatteryDataInterface;
chartOverview: BatteryOverviewInterface;
}[]
>([]);
const [isDateModalOpen, setIsDateModalOpen] = useState(false);
const [dateOpen, setDateOpen] = useState(false);
const navigate = useNavigate();
const [startDate, setStartDate] = useState(dayjs().add(-1, 'day'));
const [endDate, setEndDate] = useState(dayjs());
const [isErrorDateModalOpen, setErrorDateModalOpen] = useState(false);
const [dateSelectionError, setDateSelectionError] = useState('');
const [loading, setLoading] = useState(true);
const location = useLocation();
const blueColors = [
'#99CCFF',
'#80BFFF',
'#6699CC',
'#4D99FF',
'#2670E6',
'#3366CC',
'#1A4D99',
'#133366',
'#0D274D',
'#081A33'
];
const redColors = [
'#ff9090',
'#ff7070',
'#ff3f3f',
'#ff1e1e',
'#ff0606',
'#fc0000',
'#f40000',
'#d40000',
'#a30000',
'#7a0000'
];
const orangeColors = [
'#ffdb99',
'#ffc968',
'#ffb837',
'#ffac16',
'#ffa706',
'#FF8C00',
'#d48900',
'#CC7A00',
'#a36900',
'#993D00'
];
const greenColors = [
'#90EE90',
'#77DD77',
'#5ECE5E',
'#45BF45',
'#32CD32',
'#28A428',
'#1E7B1E',
'#145214',
'#0A390A',
'#052905'
];
useEffect(() => {
setLoading(true);
const resultPromise: Promise<{
chartData: BatteryDataInterface;
chartOverview: BatteryOverviewInterface;
}> = transformInputToBatteryViewDataJson(
props.s3Credentials,
props.id,
2,
UnixTime.fromTicks(new Date().getTime() / 1000).earlier(
TimeSpan.fromDays(1)
),
UnixTime.fromTicks(new Date().getTime() / 1000),
props.batteryClusterNumber
);
resultPromise
.then((result) => {
setBatteryViewDataArray((prevData) =>
prevData.concat({
chartData: result.chartData,
chartOverview: result.chartOverview
})
);
setLoading(false);
})
.catch((error) => {
console.error('Error:', error);
});
}, []);
const [isZooming, setIsZooming] = useState(false);
useEffect(() => {
if (isZooming) {
setLoading(true);
} else if (!isZooming && batteryViewDataArray.length > 0) {
setLoading(false);
}
}, [isZooming, batteryViewDataArray]);
function generateSeries(chartData, category, color) {
const series = [];
const pathsToSearch = [];
for (let i = 0; i < props.batteryClusterNumber; i++) {
pathsToSearch.push('Node' + i);
}
let i = 0;
pathsToSearch.forEach((devicePath) => {
if (
Object.hasOwnProperty.call(chartData[category].data, devicePath) &&
chartData[category].data[devicePath].data.length != 0
) {
series.push({
...chartData[category].data[devicePath],
color:
color === 'blue'
? blueColors[i]
: color === 'red'
? redColors[i]
: color === 'green'
? greenColors[i]
: orangeColors[i]
});
}
i++;
});
return series;
}
const handleCancel = () => {
setIsDateModalOpen(false);
setDateOpen(false);
};
const handleConfirm = () => {
setIsDateModalOpen(false);
setDateOpen(false);
if (endDate.isAfter(dayjs())) {
setDateSelectionError('You cannot ask for future data');
setErrorDateModalOpen(true);
return;
} else if (startDate.isAfter(endDate)) {
setDateSelectionError('End date must precede start date');
setErrorDateModalOpen(true);
return;
}
setLoading(true);
const resultPromise: Promise<{
chartData: BatteryDataInterface;
chartOverview: BatteryOverviewInterface;
}> = transformInputToBatteryViewDataJson(
props.s3Credentials,
props.id,
2,
UnixTime.fromTicks(startDate.unix()),
UnixTime.fromTicks(endDate.unix()),
props.batteryClusterNumber
);
resultPromise
.then((result) => {
setBatteryViewDataArray((prevData) =>
prevData.concat({
chartData: result.chartData,
chartOverview: result.chartOverview
})
);
setLoading(false);
setChartState(batteryViewDataArray.length);
})
.catch((error) => {
console.error('Error:', error);
});
};
const handleSetDate = () => {
setDateOpen(true);
setIsDateModalOpen(true);
};
const handleBatteryViewButton = () => {
navigate(
location.pathname.split('/').slice(0, -2).join('/') + '/batteryview'
);
};
const handleGoBack = () => {
if (chartState > 0) {
setChartState(chartState - 1);
}
};
const handleGoForward = () => {
if (chartState + 1 < batteryViewDataArray.length) {
setChartState(chartState + 1);
}
};
const handleOkOnErrorDateModal = () => {
setErrorDateModalOpen(false);
};
const startZoom = () => {
setIsZooming(true);
};
const handleBeforeZoom = (chartContext, { xaxis }) => {
const startX = parseInt(xaxis.min) / 1000;
const endX = parseInt(xaxis.max) / 1000;
const resultPromise: Promise<{
chartData: BatteryDataInterface;
chartOverview: BatteryOverviewInterface;
}> = transformInputToBatteryViewDataJson(
props.s3Credentials,
props.id,
2,
UnixTime.fromTicks(startX).earlier(TimeSpan.fromHours(2)),
UnixTime.fromTicks(endX).earlier(TimeSpan.fromHours(2)),
props.batteryClusterNumber
);
resultPromise
.then((result) => {
setBatteryViewDataArray((prevData) =>
prevData.concat({
chartData: result.chartData,
chartOverview: result.chartOverview
})
);
setIsZooming(false);
setChartState(batteryViewDataArray.length);
})
.catch((error) => {
console.error('Error:', error);
});
};
return (
<>
{loading && (
<Container
maxWidth="xl"
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}
>
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
<Typography variant="body2" style={{ color: 'black' }} mt={2}>
Fetching data...
</Typography>
</Container>
)}
{isErrorDateModalOpen && (
<Modal open={isErrorDateModalOpen} onClose={() => {}}>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 450,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Typography
variant="body1"
gutterBottom
sx={{ fontWeight: 'bold' }}
>
{dateSelectionError}
</Typography>
<Button
sx={{
marginTop: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={handleOkOnErrorDateModal}
>
Ok
</Button>
</Box>
</Modal>
)}
{isDateModalOpen && (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Modal open={isDateModalOpen} onClose={() => {}}>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 450,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<DateTimePicker
label="Select Start Date"
value={startDate}
onChange={(newDate: Dayjs | null) => {
if (newDate) {
setStartDate(newDate);
}
}}
renderInput={(params) => (
<TextField
{...params}
sx={{
marginTop: 2,
width: '100%'
}}
/>
)}
/>
<DateTimePicker
label="Select End Date"
value={endDate}
onChange={(newDate: Dayjs | null) => {
if (newDate) {
setEndDate(newDate);
}
}}
renderInput={(params) => (
<TextField
{...params}
sx={{
marginTop: 2,
width: '100%'
}}
/>
)}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: 10
}}
>
<Button
sx={{
marginTop: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={handleConfirm}
>
Confirm
</Button>
<Button
sx={{
marginTop: 2,
marginLeft: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={handleCancel}
>
Cancel
</Button>
</div>
</Box>
</Modal>
</LocalizationProvider>
)}
{!loading && (
<>
<Grid item xs={6} md={6}>
<IconButton
aria-label="go back"
sx={{
marginTop: '20px',
backgroundColor: 'grey',
color: '#000000',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={handleBatteryViewButton}
>
<ArrowBackIcon />
</IconButton>
<Button
variant="contained"
onClick={handleSetDate}
disabled={loading}
sx={{
marginTop: '20px',
marginLeft: '20px',
backgroundColor: dateOpen ? '#808080' : '#ffc04d',
color: '#000000',
'&:hover': { bgcolor: '#f7b34d' }
}}
>
<FormattedMessage id="set_date" defaultMessage="Set Date" />
</Button>
</Grid>
<Grid
container
justifyContent="flex-end"
alignItems="center"
item
xs={6}
md={6}
>
<Button
variant="contained"
disabled={!(chartState > 0)}
onClick={handleGoBack}
sx={{
marginTop: '20px',
marginLeft: '10px',
backgroundColor: '#ffc04d',
color: '#000000',
'&:hover': { bgcolor: '#f7b34d' }
}}
>
<FormattedMessage id="goback" defaultMessage="Zoom out" />
</Button>
<Button
variant="contained"
disabled={!(chartState < batteryViewDataArray.length - 1)}
onClick={handleGoForward}
sx={{
marginTop: '20px',
marginLeft: '10px',
backgroundColor: '#ffc04d',
color: '#000000',
'&:hover': { bgcolor: '#f7b34d' }
}}
>
<FormattedMessage id="goback" defaultMessage="Zoom in" />
</Button>
</Grid>
<Grid
container
direction="row"
justifyContent="center"
alignItems="stretch"
spacing={3}
>
{/* Battery SOC Chart */}
<Grid item md={12} xs={12}>
<Card
sx={{
overflow: 'visible',
marginTop: '30px',
marginBottom: '30px'
}}
>
<Box sx={{ marginLeft: '20px' }}>
<Box display="flex" alignItems="center">
<Box>
<Typography variant="subtitle1" noWrap>
<FormattedMessage
id="battery_soc"
defaultMessage="Battery SOC (State Of Charge)"
/>
</Typography>
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
pt: 3
}}
></Box>
</Box>
<ReactApexChart
options={{
...getChartOptions(
batteryViewDataArray[chartState].chartOverview.Soc,
'daily',
[],
true
),
chart: {
events: {
beforeZoom: (chartContext, options) => {
startZoom();
handleBeforeZoom(chartContext, options);
}
}
}
}}
series={generateSeries(
batteryViewDataArray[chartState].chartData,
'Soc',
'blue'
)}
type="line"
height={420}
/>
</Card>
</Grid>
{/* Battery Power Chart */}
<Grid item md={12} xs={12}>
<Card
sx={{
overflow: 'visible',
marginTop: '10px',
marginBottom: '30px'
}}
>
<Box sx={{ marginLeft: '20px' }}>
<Box display="flex" alignItems="center">
<Box>
<Typography variant="subtitle1" noWrap>
<FormattedMessage
id="battery_power"
defaultMessage="Battery Power"
/>
</Typography>
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
pt: 3
}}
></Box>
</Box>
<ReactApexChart
options={{
...getChartOptions(
batteryViewDataArray[chartState].chartOverview.Power,
'daily',
[],
true
),
chart: {
events: {
beforeZoom: (chartContext, options) => {
startZoom();
handleBeforeZoom(chartContext, options);
}
}
}
}}
series={generateSeries(
batteryViewDataArray[chartState].chartData,
'Power',
'red'
)}
type="line"
height={420}
/>
</Card>
</Grid>
{/* Battery Voltage Chart */}
<Grid item md={12} xs={12}>
<Card
sx={{
overflow: 'visible',
marginTop: '10px',
marginBottom: '30px'
}}
>
<Box sx={{ marginLeft: '20px' }}>
<Box display="flex" alignItems="center">
<Box>
<Typography variant="subtitle1" noWrap>
<FormattedMessage
id="battery_voltage"
defaultMessage="Battery Voltage"
/>
</Typography>
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
pt: 3
}}
></Box>
</Box>
<ReactApexChart
options={{
...getChartOptions(
batteryViewDataArray[chartState].chartOverview.Voltage,
'daily',
[],
true
),
chart: {
events: {
beforeZoom: (chartContext, options) => {
startZoom();
handleBeforeZoom(chartContext, options);
}
}
}
}}
series={generateSeries(
batteryViewDataArray[chartState].chartData,
'Voltage',
'orange'
)}
type="line"
height={420}
/>
</Card>
</Grid>
{/* Battery Current Chart */}
<Grid item md={12} xs={12}>
<Card
sx={{
overflow: 'visible',
marginTop: '10px',
marginBottom: '30px'
}}
>
<Box sx={{ marginLeft: '20px' }}>
<Box display="flex" alignItems="center">
<Box>
<Typography variant="subtitle1" noWrap>
<FormattedMessage
id="battery_current"
defaultMessage="Battery Current"
/>
</Typography>
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
pt: 3
}}
></Box>
</Box>
<ReactApexChart
options={{
...getChartOptions(
batteryViewDataArray[chartState].chartOverview.Current,
'daily',
[],
true
),
chart: {
events: {
beforeZoom: (chartContext, options) => {
startZoom();
handleBeforeZoom(chartContext, options);
}
}
}
}}
series={generateSeries(
batteryViewDataArray[chartState].chartData,
'Current',
'orange'
)}
type="line"
height={420}
/>
</Card>
</Grid>
{/* Battery SoH Chart */}
<Grid item md={12} xs={12}>
<Card
sx={{
overflow: 'visible',
marginTop: '10px',
marginBottom: '30px'
}}
>
<Box sx={{ marginLeft: '20px' }}>
<Box display="flex" alignItems="center">
<Box>
<Typography variant="subtitle1" noWrap>
<FormattedMessage
id="battery_soh"
defaultMessage="Battery SOH (State Of Health)"
/>
</Typography>
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
pt: 3
}}
></Box>
</Box>
<ReactApexChart
options={{
...getChartOptions(
batteryViewDataArray[chartState].chartOverview.Soh,
'daily',
[],
true
),
chart: {
events: {
beforeZoom: (chartContext, options) => {
startZoom();
handleBeforeZoom(chartContext, options);
}
}
}
}}
series={generateSeries(
batteryViewDataArray[chartState].chartData,
'Soh',
'green'
)}
type="line"
height={420}
/>
</Card>
</Grid>
</Grid>
</>
)}
</>
);
}
export default MainStatsSodioHome;

View File

@ -7,7 +7,6 @@ import {
FormControl, FormControl,
Grid, Grid,
IconButton, IconButton,
InputAdornment,
InputLabel, InputLabel,
MenuItem, MenuItem,
Modal, Modal,
@ -19,7 +18,7 @@ import {
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material'; import { Close as CloseIcon } from '@mui/icons-material';
import React, { useContext, useState, useEffect } from 'react'; import React, { useContext, useState, useEffect, useRef } from 'react';
import { I_S3Credentials } from '../../../interfaces/S3Types'; import { I_S3Credentials } from '../../../interfaces/S3Types';
import { I_Installation } from '../../../interfaces/InstallationTypes'; import { I_Installation } from '../../../interfaces/InstallationTypes';
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider'; import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
@ -57,8 +56,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
{ id: 4, name: 'Sinexcel' } { id: 4, name: 'Sinexcel' }
]; ];
const BATTERY_SN_PREFIX = 'PNR020125101'; const batterySnRefs = useRef<(HTMLInputElement | null)[]>([]);
const BATTERY_SN_SUFFIX_LENGTH = 4;
// Initialize battery data from props // Initialize battery data from props
useEffect(() => { useEffect(() => {
@ -68,14 +66,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
if (props.values.batterySerialNumbers) { if (props.values.batterySerialNumbers) {
const serialNumbers = props.values.batterySerialNumbers const serialNumbers = props.values.batterySerialNumbers
.split(',') .split(',')
.filter((sn) => sn.trim() !== '') .filter((sn) => sn.trim() !== '');
.map((sn) => {
// If it has the prefix, extract only the suffix
if (sn.startsWith(BATTERY_SN_PREFIX)) {
return sn.substring(BATTERY_SN_PREFIX.length);
}
return sn;
});
setBatterySerialNumbers(serialNumbers); setBatterySerialNumbers(serialNumbers);
} }
}, []); }, []);
@ -107,41 +98,47 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
const value = inputValue === '' ? 0 : parseInt(inputValue); const value = inputValue === '' ? 0 : parseInt(inputValue);
setBatteryNumber(value); setBatteryNumber(value);
// Preserve existing serial numbers and adjust array size if (value > 0) {
const newSerialNumbers = Array.from({ length: value }, (_, index) => { // Resize array: preserve existing serial numbers, add empty for new slots
// Keep existing serial number if it exists, otherwise use empty string const newSerialNumbers = Array.from({ length: value }, (_, index) => {
return batterySerialNumbers[index] || ''; return batterySerialNumbers[index] || '';
}); });
setBatterySerialNumbers(newSerialNumbers); setBatterySerialNumbers(newSerialNumbers);
// Update formValues with preserved serial numbers setFormValues({
const fullSerialNumbers = newSerialNumbers ...formValues,
.map((suffix) => (suffix ? BATTERY_SN_PREFIX + suffix : '')) batteryNumber: value,
.filter((sn) => sn !== ''); batterySerialNumbers: newSerialNumbers.filter((sn) => sn !== '').join(',')
});
setFormValues({ } else {
...formValues, // Field is empty (user is mid-edit) — don't clear serial numbers
batteryNumber: value, setFormValues({
batterySerialNumbers: fullSerialNumbers.join(',') ...formValues,
}); batteryNumber: 0
});
}
} }
}; };
const handleBatterySerialNumberChange = (index: number, value: string) => { const handleBatterySerialNumberChange = (index: number, value: string) => {
// Only allow digits and limit to 3 characters
const sanitizedValue = value.replace(/\D/g, '').substring(0, BATTERY_SN_SUFFIX_LENGTH);
const updatedSerialNumbers = [...batterySerialNumbers]; const updatedSerialNumbers = [...batterySerialNumbers];
updatedSerialNumbers[index] = sanitizedValue; updatedSerialNumbers[index] = value;
setBatterySerialNumbers(updatedSerialNumbers); setBatterySerialNumbers(updatedSerialNumbers);
// Update formValues for persistence with full serial numbers (prefix + suffix)
const fullSerialNumbers = updatedSerialNumbers
.map((suffix) => (suffix ? BATTERY_SN_PREFIX + suffix : ''))
.filter((sn) => sn !== '');
setFormValues({ setFormValues({
...formValues, ...formValues,
batterySerialNumbers: fullSerialNumbers.join(',') batterySerialNumbers: updatedSerialNumbers.filter((sn) => sn !== '').join(',')
}); });
}; };
const handleBatterySnKeyDown = (e: React.KeyboardEvent, index: number) => {
if (e.key === 'Enter') {
e.preventDefault();
const nextIndex = index + 1;
if (nextIndex < batteryNumber && batterySnRefs.current[nextIndex]) {
batterySnRefs.current[nextIndex].focus();
}
}
};
const handleSubmit = () => { const handleSubmit = () => {
setLoading(true); setLoading(true);
setError(false); setError(false);
@ -467,20 +464,11 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
onChange={(e) => onChange={(e) =>
handleBatterySerialNumberChange(index, e.target.value) handleBatterySerialNumberChange(index, e.target.value)
} }
onKeyDown={(e) => handleBatterySnKeyDown(e, index)}
inputRef={(el) => (batterySnRefs.current[index] = el)}
variant="outlined" variant="outlined"
fullWidth fullWidth
InputProps={{ placeholder="Scan or enter serial number"
startAdornment: (
<InputAdornment position="start">
{BATTERY_SN_PREFIX}
</InputAdornment>
)
}}
inputProps={{
maxLength: BATTERY_SN_SUFFIX_LENGTH,
placeholder: '0000'
}}
helperText={`Enter ${BATTERY_SN_SUFFIX_LENGTH} digits`}
/> />
</div> </div>
))} ))}

View File

@ -118,6 +118,12 @@ function Overview(props: OverviewProps) {
resultPromise resultPromise
.then((result) => { .then((result) => {
if (result.chartData.soc.data.length === 0) {
setDateSelectionError('No data available for the selected date range. Please choose a more recent date.');
setErrorDateModalOpen(true);
setLoading(false);
return;
}
setDailyDataArray((prevData) => setDailyDataArray((prevData) =>
prevData.concat({ prevData.concat({
chartData: result.chartData, chartData: result.chartData,
@ -281,6 +287,12 @@ function Overview(props: OverviewProps) {
resultPromise resultPromise
.then((result) => { .then((result) => {
if (result.chartData.soc.data.length === 0) {
setDateSelectionError('No data available for the selected date range. Please choose a more recent date.');
setErrorDateModalOpen(true);
setLoading(false);
return;
}
setDailyDataArray((prevData) => setDailyDataArray((prevData) =>
prevData.concat({ prevData.concat({
chartData: result.chartData, chartData: result.chartData,
@ -511,20 +523,22 @@ function Overview(props: OverviewProps) {
> >
<FormattedMessage id="24_hours" defaultMessage="24-hours" /> <FormattedMessage id="24_hours" defaultMessage="24-hours" />
</Button> </Button>
<Button {product !== 2 && (
variant="contained" <Button
onClick={handleWeekData} variant="contained"
disabled={loading} onClick={handleWeekData}
sx={{ disabled={loading}
marginTop: '20px', sx={{
marginLeft: '10px', marginTop: '20px',
backgroundColor: aggregatedData ? '#808080' : '#ffc04d', marginLeft: '10px',
color: '#000000', backgroundColor: aggregatedData ? '#808080' : '#ffc04d',
'&:hover': { bgcolor: '#f7b34d' } color: '#000000',
}} '&:hover': { bgcolor: '#f7b34d' }
> }}
<FormattedMessage id="lastweek" defaultMessage="Last week" /> >
</Button> <FormattedMessage id="lastweek" defaultMessage="Last week" />
</Button>
)}
{/*{aggregatedData && (*/} {/*{aggregatedData && (*/}
<Button <Button
@ -1013,7 +1027,7 @@ function Overview(props: OverviewProps) {
alignItems="stretch" alignItems="stretch"
spacing={3} spacing={3}
> >
<Grid item md={6} xs={12}> <Grid item md={product === 2 ? 12 : 6} xs={12}>
<Card <Card
sx={{ sx={{
overflow: 'visible', overflow: 'visible',
@ -1074,6 +1088,7 @@ function Overview(props: OverviewProps) {
/> />
</Card> </Card>
</Grid> </Grid>
{product !== 2 && (
<Grid item md={6} xs={12}> <Grid item md={6} xs={12}>
<Card <Card
sx={{ sx={{
@ -1136,6 +1151,7 @@ function Overview(props: OverviewProps) {
/> />
</Card> </Card>
</Grid> </Grid>
)}
</Grid> </Grid>
)} )}
@ -1336,7 +1352,7 @@ function Overview(props: OverviewProps) {
alignItems="stretch" alignItems="stretch"
spacing={3} spacing={3}
> >
<Grid item md={6} xs={12}> <Grid item md={product === 2 ? 12 : 6} xs={12}>
<Card <Card
sx={{ sx={{
overflow: 'visible', overflow: 'visible',
@ -1397,6 +1413,7 @@ function Overview(props: OverviewProps) {
/> />
</Card> </Card>
</Grid> </Grid>
{product !== 2 && (
<Grid item md={6} xs={12}> <Grid item md={6} xs={12}>
<Card <Card
sx={{ sx={{
@ -1458,6 +1475,7 @@ function Overview(props: OverviewProps) {
/> />
</Card> </Card>
</Grid> </Grid>
)}
</Grid> </Grid>
)} )}
</Grid> </Grid>

View File

@ -26,6 +26,7 @@ import { FetchResult } from '../../../dataCache/dataCache';
import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome'; import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome';
import SodistoreHomeConfiguration from './SodistoreHomeConfiguration'; import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
import TopologySodistoreHome from '../Topology/TopologySodistoreHome'; import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
import Overview from '../Overview/overview';
interface singleInstallationProps { interface singleInstallationProps {
current_installation?: I_Installation; current_installation?: I_Installation;
@ -182,11 +183,13 @@ function SodioHomeInstallation(props: singleInstallationProps) {
}; };
const fetchDataForOneTime = async () => { const fetchDataForOneTime = async () => {
var timeperiodToSearch = 200; var timeperiodToSearch = 300; // 5 minutes to cover ~4 upload cycles
let res; let res;
let timestampToFetch; let timestampToFetch;
for (var i = timeperiodToSearch; i > 0; i -= 2) { // Search from NOW backward to find the most recent data
// Step by 10 seconds - balances between finding files quickly and reducing 404s
for (var i = 0; i < timeperiodToSearch; i += 10) {
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i)); timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
try { try {
res = await fetchDataJson(timestampToFetch, s3Credentials, false); res = await fetchDataJson(timestampToFetch, s3Credentials, false);
@ -199,7 +202,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
} }
} }
if (i <= 0) { if (i >= timeperiodToSearch) {
setConnected(false); setConnected(false);
setLoading(false); setLoading(false);
return false; return false;
@ -207,8 +210,10 @@ function SodioHomeInstallation(props: singleInstallationProps) {
setConnected(true); setConnected(true);
setLoading(false); setLoading(false);
const timestamp = Object.keys(res)[Object.keys(res).length - 1]; // Sort timestamps numerically to ensure we get the most recent data point
setValues(res[timestamp]); const timestamps = Object.keys(res).sort((a, b) => Number(b) - Number(a));
const latestTimestamp = timestamps[0];
setValues(res[latestTimestamp]);
// setValues( // setValues(
// extractValues({ // extractValues({
// time: UnixTime.fromTicks(parseInt(timestamp, 10)), // time: UnixTime.fromTicks(parseInt(timestamp, 10)),
@ -251,9 +256,19 @@ function SodioHomeInstallation(props: singleInstallationProps) {
} }
} }
} }
// Fetch only one time in configuration tab // Fetch periodically in configuration tab (every 30 seconds to detect S3 updates)
if (currentTab == 'configuration') { if (currentTab == 'configuration') {
fetchDataForOneTime(); fetchDataForOneTime(); // Initial fetch
const configRefreshInterval = setInterval(() => {
console.log('Refreshing configuration data from S3...');
fetchDataForOneTime();
}, 15000); // Refresh every 15 seconds
return () => {
continueFetching.current = false;
clearInterval(configRefreshInterval);
};
} }
return () => { return () => {
@ -527,6 +542,16 @@ function SodioHomeInstallation(props: singleInstallationProps) {
/> />
)} )}
<Route
path={routes.overview}
element={
<Overview
s3Credentials={s3Credentials}
id={props.current_installation.id}
/>
}
/>
<Route <Route
path={'*'} path={'*'}
element={<Navigate to={routes.live}></Navigate>} element={<Navigate to={routes.live}></Navigate>}

View File

@ -16,7 +16,7 @@ import {
useTheme useTheme
} from '@mui/material'; } from '@mui/material';
import React, { useContext, useState } from 'react'; import React, { useContext, useState, useEffect } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material'; import { Close as CloseIcon } from '@mui/icons-material';
@ -105,6 +105,78 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true", controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
}); });
// Storage key for pending config (optimistic update)
const pendingConfigKey = `pendingConfig_${props.id}`;
// Helper to get current S3 values
const getS3Values = () => ({
minimumSoC: props.values.Config.MinSoc,
maximumDischargingCurrent: props.values.Config.MaximumChargingCurrent,
maximumChargingCurrent: props.values.Config.MaximumDischargingCurrent,
operatingPriority: OperatingPriorityOptions.indexOf(
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",
});
// Sync form values when props.values changes
// Logic: Use localStorage only briefly after submit to prevent flicker, then trust S3
useEffect(() => {
const s3Values = getS3Values();
const pendingConfigStr = localStorage.getItem(pendingConfigKey);
if (pendingConfigStr) {
try {
const pendingConfig = JSON.parse(pendingConfigStr);
const submittedAt = pendingConfig.submittedAt || 0;
const timeSinceSubmit = Date.now() - submittedAt;
// Within 150 seconds of submit: use localStorage (waiting for S3 sync)
// This covers two full S3 upload cycles (75 sec × 2) to ensure new file is available
if (timeSinceSubmit < 150000) {
// Check if S3 now matches - if so, sync is complete
const s3MatchesPending =
s3Values.controlPermission === pendingConfig.values.controlPermission &&
s3Values.minimumSoC === pendingConfig.values.minimumSoC &&
s3Values.operatingPriority === pendingConfig.values.operatingPriority;
if (s3MatchesPending) {
// S3 synced! Clear localStorage and use S3 from now on
console.log('S3 synced with submitted config');
localStorage.removeItem(pendingConfigKey);
setFormValues(s3Values);
} else {
// Still waiting for sync, keep showing submitted values
console.log('Waiting for S3 sync, showing submitted values');
setFormValues(pendingConfig.values);
}
return;
}
// Timeout expired: clear localStorage, trust S3 completely
console.log('Timeout expired, trusting S3 data');
localStorage.removeItem(pendingConfigKey);
} catch (e) {
localStorage.removeItem(pendingConfigKey);
}
}
// No localStorage or expired: always use S3 (source of truth)
setFormValues(s3Values);
}, [props.values]);
const handleOperatingPriorityChange = (event) => { const handleOperatingPriorityChange = (event) => {
setFormValues({ setFormValues({
...formValues, ...formValues,
@ -173,6 +245,13 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) {
if (res) { if (res) {
setUpdated(true); setUpdated(true);
setLoading(false); setLoading(false);
// Save submitted values to localStorage for optimistic UI update
// This ensures the form shows correct values even before S3 syncs (up to 75 sec delay)
localStorage.setItem(pendingConfigKey, JSON.stringify({
values: formValues,
submittedAt: Date.now()
}));
} }
}; };

View File

@ -24,10 +24,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
const { currentUser } = context; const { currentUser } = context;
const tabList = [ const tabList = [
'live', 'live',
'overview',
'batteryview', 'batteryview',
'information', 'information',
'manage', 'manage',
'overview',
'log', 'log',
'history', 'history',
'configuration' 'configuration'
@ -100,6 +100,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
value: 'live', value: 'live',
label: <FormattedMessage id="live" defaultMessage="Live" /> label: <FormattedMessage id="live" defaultMessage="Live" />
}, },
{
value: 'overview',
label: <FormattedMessage id="overview" defaultMessage="Overview" />
},
{ {
value: 'batteryview', value: 'batteryview',
label: ( label: (
@ -109,10 +113,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
/> />
) )
}, },
// {
// value: 'overview',
// label: <FormattedMessage id="overview" defaultMessage="Overview" />
// },
{ {
value: 'log', value: 'log',
label: <FormattedMessage id="log" defaultMessage="Log" /> label: <FormattedMessage id="log" defaultMessage="Log" />
@ -159,11 +159,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
value: 'live', value: 'live',
label: <FormattedMessage id="live" defaultMessage="Live" /> label: <FormattedMessage id="live" defaultMessage="Live" />
}, },
// { {
// value: 'overview', value: 'overview',
// label: <FormattedMessage id="overview" defaultMessage="Overview" /> label: <FormattedMessage id="overview" defaultMessage="Overview" />
// }, },
{ {
value: 'information', value: 'information',
label: ( label: (
@ -190,6 +189,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
value: 'live', value: 'live',
label: <FormattedMessage id="live" defaultMessage="Live" /> label: <FormattedMessage id="live" defaultMessage="Live" />
}, },
{
value: 'overview',
label: <FormattedMessage id="overview" defaultMessage="Overview" />
},
{ {
value: 'batteryview', value: 'batteryview',
label: ( label: (
@ -199,10 +202,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
/> />
) )
}, },
// {
// value: 'overview',
// label: <FormattedMessage id="overview" defaultMessage="Overview" />
// },
{ {
value: 'log', value: 'log',
label: <FormattedMessage id="log" defaultMessage="Log" /> label: <FormattedMessage id="log" defaultMessage="Log" />

View File

@ -60,6 +60,7 @@ export interface BatteryDataInterface {
Power: { name: string; data: [] }; Power: { name: string; data: [] };
Voltage: { name: string; data: [] }; Voltage: { name: string; data: [] };
Current: { name: string; data: [] }; Current: { name: string; data: [] };
Soh?: { name: string; data: [] };
} }
export interface BatteryOverviewInterface { export interface BatteryOverviewInterface {
@ -68,6 +69,7 @@ export interface BatteryOverviewInterface {
Power: chartInfoInterface; Power: chartInfoInterface;
Voltage: chartInfoInterface; Voltage: chartInfoInterface;
Current: chartInfoInterface; Current: chartInfoInterface;
Soh?: chartInfoInterface;
} }
export const transformInputToBatteryViewDataJson = async ( export const transformInputToBatteryViewDataJson = async (
@ -75,14 +77,18 @@ export const transformInputToBatteryViewDataJson = async (
id: number, id: number,
product: number, product: number,
start_time?: UnixTime, start_time?: UnixTime,
end_time?: UnixTime end_time?: UnixTime,
batteryClusterNumber?: number
): Promise<{ ): Promise<{
chartData: BatteryDataInterface; chartData: BatteryDataInterface;
chartOverview: BatteryOverviewInterface; chartOverview: BatteryOverviewInterface;
}> => { }> => {
const prefixes = ['', 'k', 'M', 'G', 'T']; const prefixes = ['', 'k', 'M', 'G', 'T'];
const MAX_NUMBER = 9999999; const MAX_NUMBER = 9999999;
const categories = ['Soc', 'Temperature', 'Power', 'Voltage', 'Current']; const isSodioHome = product === 2;
const categories = isSodioHome
? ['Soc', 'Power', 'Voltage', 'Current', 'Soh']
: ['Soc', 'Temperature', 'Power', 'Voltage', 'Current'];
const pathCategories = const pathCategories =
product === 3 product === 3
? [ ? [
@ -120,7 +126,8 @@ export const transformInputToBatteryViewDataJson = async (
Temperature: { name: 'Temperature', data: [] }, Temperature: { name: 'Temperature', data: [] },
Power: { name: 'Power', data: [] }, Power: { name: 'Power', data: [] },
Voltage: { name: 'Voltage', data: [] }, Voltage: { name: 'Voltage', data: [] },
Current: { name: 'Current', data: [] } Current: { name: 'Current', data: [] },
...(isSodioHome && { Soh: { name: 'State Of Health', data: [] } })
}; };
const chartOverview: BatteryOverviewInterface = { const chartOverview: BatteryOverviewInterface = {
@ -128,7 +135,8 @@ export const transformInputToBatteryViewDataJson = async (
Temperature: { magnitude: 0, unit: '', min: 0, max: 0 }, Temperature: { magnitude: 0, unit: '', min: 0, max: 0 },
Power: { magnitude: 0, unit: '', min: 0, max: 0 }, Power: { magnitude: 0, unit: '', min: 0, max: 0 },
Voltage: { magnitude: 0, unit: '', min: 0, max: 0 }, Voltage: { magnitude: 0, unit: '', min: 0, max: 0 },
Current: { magnitude: 0, unit: '', min: 0, max: 0 } Current: { magnitude: 0, unit: '', min: 0, max: 0 },
...(isSodioHome && { Soh: { magnitude: 0, unit: '', min: 0, max: 0 } })
}; };
let initialiation = true; let initialiation = true;
@ -159,7 +167,7 @@ export const transformInputToBatteryViewDataJson = async (
); );
const adjustedTimestamp = const adjustedTimestamp =
product == 0 || product == 3 product == 0 || product == 2 || product == 3
? new Date(timestampArray[i] * 1000) ? new Date(timestampArray[i] * 1000)
: new Date(timestampArray[i] * 100000); : new Date(timestampArray[i] * 100000);
//Timezone offset is negative, so we convert the timestamp to the current zone by subtracting the corresponding offset //Timezone offset is negative, so we convert the timestamp to the current zone by subtracting the corresponding offset
@ -181,79 +189,144 @@ export const transformInputToBatteryViewDataJson = async (
]; ];
const result = results[i][timestamp]; const result = results[i][timestamp];
//console.log(result);
const battery_nodes =
result.Config.Devices.BatteryNodes.toString().split(',');
//Initialize the chartData structure based on the node names extracted from the first result if (isSodioHome) {
let old_length = pathsToSave.length; // SodistoreHome: extract battery data from InverterRecord
const inv = (result as any)?.InverterRecord;
if (!inv) continue;
if (battery_nodes.length > old_length) { const numBatteries = batteryClusterNumber || 1;
battery_nodes.forEach((node) => { let old_length = pathsToSave.length;
const node_number =
product == 3 ? Number(node) + 1 : Number(node) - 1;
if (!pathsToSave.includes('Node' + node_number)) {
pathsToSave.push('Node' + node_number);
}
});
}
// console.log(pathsToSave); if (numBatteries > old_length) {
for (let b = old_length; b < numBatteries; b++) {
if (initialiation) { const nodeName = 'Node' + b;
initialiation = false; if (!pathsToSave.includes(nodeName)) {
categories.forEach((category) => { pathsToSave.push(nodeName);
chartData[category].data = [];
chartOverview[category] = {
magnitude: 0,
unit: '',
min: MAX_NUMBER,
max: -MAX_NUMBER
};
});
}
if (battery_nodes.length > old_length) {
categories.forEach((category) => {
pathsToSave.forEach((path) => {
if (pathsToSave.indexOf(path) >= old_length) {
chartData[category].data[path] = { name: path, data: [] };
} }
}); }
}); }
}
for ( if (initialiation) {
let category_index = 0; initialiation = false;
category_index < pathCategories.length; categories.forEach((category) => {
category_index++ chartData[category].data = [];
) { chartOverview[category] = {
let category = categories[category_index]; magnitude: 0,
unit: '',
min: MAX_NUMBER,
max: -MAX_NUMBER
};
});
}
if (numBatteries > old_length) {
categories.forEach((category) => {
pathsToSave.forEach((path) => {
if (pathsToSave.indexOf(path) >= old_length) {
chartData[category].data[path] = { name: path, data: [] };
}
});
});
}
// Map category names to InverterRecord field suffixes
const categoryFieldMap = {
Soc: 'Soc',
Power: 'Power',
Voltage: 'Voltage',
Current: 'Current',
Soh: 'Soh'
};
for (let j = 0; j < pathsToSave.length; j++) { for (let j = 0; j < pathsToSave.length; j++) {
let path = pathsToSearch[j] + pathCategories[category_index]; const batteryIndex = j + 1; // Battery1, Battery2, ...
categories.forEach((category) => {
const fieldName = `Battery${batteryIndex}${categoryFieldMap[category]}`;
const value = inv[fieldName];
if (get(result, path) !== undefined) { if (value !== undefined && value !== null) {
const value = path if (value < chartOverview[category].min) {
.split('.') chartOverview[category].min = value;
.reduce((o, key) => (o ? o[key] : undefined), result); }
if (value > chartOverview[category].max) {
if (value < chartOverview[category].min) { chartOverview[category].max = value;
chartOverview[category].min = value; }
chartData[category].data[pathsToSave[j]].data.push([
adjustedTimestampArray[i],
value
]);
} }
});
}
} else {
// SaliMax, Salidomo, SodistoreMax: existing logic
const battery_nodes =
result.Config.Devices.BatteryNodes.toString().split(',');
if (value > chartOverview[category].max) { //Initialize the chartData structure based on the node names extracted from the first result
chartOverview[category].max = value; let old_length = pathsToSave.length;
if (battery_nodes.length > old_length) {
battery_nodes.forEach((node) => {
const node_number =
product == 3 ? Number(node) + 1 : Number(node) - 1;
if (!pathsToSave.includes('Node' + node_number)) {
pathsToSave.push('Node' + node_number);
}
});
}
if (initialiation) {
initialiation = false;
categories.forEach((category) => {
chartData[category].data = [];
chartOverview[category] = {
magnitude: 0,
unit: '',
min: MAX_NUMBER,
max: -MAX_NUMBER
};
});
}
if (battery_nodes.length > old_length) {
categories.forEach((category) => {
pathsToSave.forEach((path) => {
if (pathsToSave.indexOf(path) >= old_length) {
chartData[category].data[path] = { name: path, data: [] };
}
});
});
}
for (
let category_index = 0;
category_index < pathCategories.length;
category_index++
) {
let category = categories[category_index];
for (let j = 0; j < pathsToSave.length; j++) {
let path = pathsToSearch[j] + pathCategories[category_index];
if (get(result, path) !== undefined) {
const value = path
.split('.')
.reduce((o, key) => (o ? o[key] : undefined), result);
if (value < chartOverview[category].min) {
chartOverview[category].min = value;
}
if (value > chartOverview[category].max) {
chartOverview[category].max = value;
}
chartData[category].data[pathsToSave[j]].data.push([
adjustedTimestampArray[i],
value
]);
} }
chartData[category].data[pathsToSave[j]].data.push([
adjustedTimestampArray[i],
value
]);
} else {
// chartData[category].data[pathsToSave[j]].data.push([
// adjustedTimestampArray[i],
// null
// ]);
} }
} }
} }
@ -280,16 +353,20 @@ export const transformInputToBatteryViewDataJson = async (
chartOverview.Soc.unit = '(%)'; chartOverview.Soc.unit = '(%)';
chartOverview.Soc.min = 0; chartOverview.Soc.min = 0;
chartOverview.Soc.max = 100; chartOverview.Soc.max = 100;
chartOverview.Temperature.unit = '(°C)'; if (!isSodioHome) {
chartOverview.Temperature.unit = '(°C)';
}
chartOverview.Power.unit = chartOverview.Power.unit =
'(' + prefixes[chartOverview['Power'].magnitude] + 'W' + ')'; '(' + prefixes[chartOverview['Power'].magnitude] + 'W' + ')';
chartOverview.Voltage.unit = chartOverview.Voltage.unit =
'(' + prefixes[chartOverview['Voltage'].magnitude] + 'V' + ')'; '(' + prefixes[chartOverview['Voltage'].magnitude] + 'V' + ')';
chartOverview.Current.unit = chartOverview.Current.unit =
'(' + prefixes[chartOverview['Current'].magnitude] + 'A' + ')'; '(' + prefixes[chartOverview['Current'].magnitude] + 'A' + ')';
if (isSodioHome) {
// console.log(chartData); chartOverview.Soh.unit = '(%)';
chartOverview.Soh.min = 0;
chartOverview.Soh.max = 100;
}
return { return {
chartData: chartData, chartData: chartData,
@ -310,21 +387,31 @@ export const transformInputToDailyDataJson = async (
const prefixes = ['', 'k', 'M', 'G', 'T']; const prefixes = ['', 'k', 'M', 'G', 'T'];
const MAX_NUMBER = 9999999; const MAX_NUMBER = 9999999;
const pathsToSearch = [ // For SodioHome (product=2), paths are placeholders — actual extraction uses
'Battery.Soc', // custom fallback logic to handle differences between Growatt and Sinexcel.
product == 0 ? 'Battery.Temperature' : 'Battery.TemperatureCell1', // Growatt has: Battery1AmbientTemperature, GridPower, PvPower
// Sinexcel has: Battery1Temperature, TotalGridPower (meter may be offline), PvPower1-4
//'Battery.Temperature' for salimax, const pathsToSearch = product == 2
//'Battery.TemperatureCell1', ? [
product == 0 ? 'Battery.Dc.Power' : 'Battery.Power', 'SODIOHOME_SOC',
//'Battery.Dc.Power' for salimax, 'SODIOHOME_TEMPERATURE',
// 'Battery.Power', 'SODIOHOME_BATTERY_POWER',
'GridMeter.Ac.Power.Active', 'SODIOHOME_GRID_POWER',
'PvOnDc', 'SODIOHOME_PV_POWER',
'DcDc.Dc.Link.Voltage', null, // dcBusVoltage not available for SodioHome
'LoadOnAcGrid.Power.Active', 'SODIOHOME_CONSUMPTION',
'LoadOnDc.Power' null // DCLoad not available for SodioHome
]; ]
: [
'Battery.Soc',
product == 0 ? 'Battery.Temperature' : 'Battery.TemperatureCell1',
product == 0 ? 'Battery.Dc.Power' : 'Battery.Power',
'GridMeter.Ac.Power.Active',
'PvOnDc',
'DcDc.Dc.Link.Voltage',
'LoadOnAcGrid.Power.Active',
'LoadOnDc.Power'
];
const categories = [ const categories = [
'soc', 'soc',
'temperature', 'temperature',
@ -419,37 +506,78 @@ export const transformInputToDailyDataJson = async (
let category_index = 0; let category_index = 0;
// eslint-disable-next-line @typescript-eslint/no-loop-func // eslint-disable-next-line @typescript-eslint/no-loop-func
pathsToSearch.forEach((path) => { pathsToSearch.forEach((path) => {
if (get(result, path) !== undefined) { if (path === null) {
let value: number | undefined = undefined; // Skip unavailable fields (e.g. dcBusVoltage, DCLoad for SodioHome)
category_index++;
return;
}
if (category_index === 4) { let value: number | undefined = undefined;
// Custom logic for 'PvOnDc.Dc.Power'
if (product === 2) {
// SodioHome: custom extraction with fallbacks for Growatt/Sinexcel
const inv = result?.InverterRecord;
if (inv) {
switch (category_index) {
case 0: // soc
value = inv.Battery1Soc;
break;
case 1: // temperature
// Growatt: Battery1AmbientTemperature, Sinexcel: Battery1Temperature
value = inv.Battery1AmbientTemperature ?? inv.Battery1Temperature;
break;
case 2: // battery power
value = inv.Battery1Power;
break;
case 3: // grid power
// Growatt: GridPower (always valid), Sinexcel: GridPower may be 0 when
// electric meter is offline, TotalGridPower is the reliable fallback
value = inv.TotalGridPower ?? inv.GridPower;
break;
case 4: // pv production
// Growatt: PvPower (aggregated), Sinexcel: PvTotalPower or sum PvPower1-4
value =
inv.PvPower ??
inv.PvTotalPower ??
['PvPower1', 'PvPower2', 'PvPower3', 'PvPower4']
.map((key) => inv[key] ?? 0)
.reduce((sum, val) => sum + val, 0);
break;
case 6: // consumption
value = inv.ConsumptionPower;
break;
}
}
} else if (category_index === 4) {
// Salimax/Salidomo: Custom logic for 'PvOnDc.Dc.Power'
if (get(result, path) !== undefined) {
value = Object.values( value = Object.values(
result.PvOnDc as Record<string, { Dc?: { Power?: number } }> result.PvOnDc as Record<string, { Dc?: { Power?: number } }>
).reduce((sum, device) => sum + (device.Dc?.Power || 0), 0); ).reduce((sum, device) => sum + (device.Dc?.Power || 0), 0);
} else if (get(result, path) !== undefined) {
// Default path-based extraction
value = path
.split('.')
.reduce((o, key) => (o ? o[key] : undefined), result);
}
// Only push value if defined
if (value !== undefined) {
if (value < chartOverview[categories[category_index]].min) {
chartOverview[categories[category_index]].min = value;
}
if (value > chartOverview[categories[category_index]].max) {
chartOverview[categories[category_index]].max = value;
}
chartData[categories[category_index]].data.push([
adjustedTimestampArray[i],
value
]);
} }
} else if (get(result, path) !== undefined) {
// Default path-based extraction
value = path
.split('.')
.reduce((o, key) => (o ? o[key] : undefined), result);
} }
// Only push value if defined
if (value !== undefined) {
if (value < chartOverview[categories[category_index]].min) {
chartOverview[categories[category_index]].min = value;
}
if (value > chartOverview[categories[category_index]].max) {
chartOverview[categories[category_index]].max = value;
}
chartData[categories[category_index]].data.push([
adjustedTimestampArray[i],
value
]);
}
category_index++; category_index++;
}); });
} }