diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index bf0fb6383..e8f508340 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -195,9 +195,13 @@ public class Controller : ControllerBase while (startTimestamp <= endTimestamp) { - string bucketPath = installation.Product==(int)ProductType.Salimax || installation.Product==(int)ProductType.SodiStoreMax? - "s3://"+installation.S3BucketId + "-3e5b3069-214a-43ee-8d85-57d72000c19d/"+startTimestamp : - "s3://"+installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/"+startTimestamp; + string bucketPath; + if (installation.Product == (int)ProductType.Salimax || installation.Product == (int)ProductType.SodiStoreMax) + 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); try diff --git a/csharp/App/Backend/db-1733849565.sqlite b/csharp/App/Backend/db-1733849565.sqlite deleted file mode 100644 index f3b3e33a3..000000000 Binary files a/csharp/App/Backend/db-1733849565.sqlite and /dev/null differ diff --git a/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryViewSodioHome.tsx b/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryViewSodioHome.tsx index db6b47d6c..fb3e221d3 100644 --- a/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryViewSodioHome.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryViewSodioHome.tsx @@ -12,13 +12,14 @@ import { Typography } from '@mui/material'; 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 { FormattedMessage } from 'react-intl'; import { I_S3Credentials } from '../../../interfaces/S3Types'; import routes from '../../../Resources/routes.json'; import CircularProgress from '@mui/material/CircularProgress'; import { I_Installation } from 'src/interfaces/InstallationTypes'; +import MainStatsSodioHome from './MainStatsSodioHome'; interface BatteryViewSodioHomeProps { values: JSONRecordData; @@ -164,54 +165,20 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) { - {/**/} - {/* */} - {/* */} - {/* }*/} - {/* />*/} - {/* {product === 0*/} - {/* ? Object.entries(props.values.Battery.Devices).map(*/} - {/* ([BatteryId, battery]) => (*/} - {/* */} - {/* }*/} - {/* />*/} - {/* )*/} - {/* )*/} - {/* : Object.entries(props.values.Battery.Devices).map(*/} - {/* ([BatteryId, battery]) => (*/} - {/* */} - {/* }*/} - {/* />*/} - {/* )*/} - {/* )}*/} - {/* */} - {/**/} + + + + } + /> + + ([]); + + 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 && ( + + + + Fetching data... + + + )} + {isErrorDateModalOpen && ( + {}}> + + + {dateSelectionError} + + + + Ok + + + + )} + {isDateModalOpen && ( + + {}}> + + { + if (newDate) { + setStartDate(newDate); + } + }} + renderInput={(params) => ( + + )} + /> + + { + if (newDate) { + setEndDate(newDate); + } + }} + renderInput={(params) => ( + + )} + /> + + + + Confirm + + + + Cancel + + + + + + )} + + {!loading && ( + <> + + + + + + + + + + + 0)} + onClick={handleGoBack} + sx={{ + marginTop: '20px', + marginLeft: '10px', + backgroundColor: '#ffc04d', + color: '#000000', + '&:hover': { bgcolor: '#f7b34d' } + }} + > + + + + + + + + + {/* Battery SOC Chart */} + + + + + + + + + + + + + + { + startZoom(); + handleBeforeZoom(chartContext, options); + } + } + } + }} + series={generateSeries( + batteryViewDataArray[chartState].chartData, + 'Soc', + 'blue' + )} + type="line" + height={420} + /> + + + + {/* Battery Power Chart */} + + + + + + + + + + + + + + { + startZoom(); + handleBeforeZoom(chartContext, options); + } + } + } + }} + series={generateSeries( + batteryViewDataArray[chartState].chartData, + 'Power', + 'red' + )} + type="line" + height={420} + /> + + + + {/* Battery Voltage Chart */} + + + + + + + + + + + + + + { + startZoom(); + handleBeforeZoom(chartContext, options); + } + } + } + }} + series={generateSeries( + batteryViewDataArray[chartState].chartData, + 'Voltage', + 'orange' + )} + type="line" + height={420} + /> + + + + {/* Battery Current Chart */} + + + + + + + + + + + + + + { + startZoom(); + handleBeforeZoom(chartContext, options); + } + } + } + }} + series={generateSeries( + batteryViewDataArray[chartState].chartData, + 'Current', + 'orange' + )} + type="line" + height={420} + /> + + + + {/* Battery SoH Chart */} + + + + + + + + + + + + + + { + startZoom(); + handleBeforeZoom(chartContext, options); + } + } + } + }} + series={generateSeries( + batteryViewDataArray[chartState].chartData, + 'Soh', + 'green' + )} + type="line" + height={420} + /> + + + + > + )} + > + ); +} + +export default MainStatsSodioHome; diff --git a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx index 4b1cf5184..cebd9afbb 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx @@ -7,7 +7,6 @@ import { FormControl, Grid, IconButton, - InputAdornment, InputLabel, MenuItem, Modal, @@ -19,7 +18,7 @@ import { import { FormattedMessage } from 'react-intl'; import Button from '@mui/material/Button'; 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_Installation } from '../../../interfaces/InstallationTypes'; import { InstallationsContext } from '../../../contexts/InstallationsContextProvider'; @@ -57,8 +56,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { { id: 4, name: 'Sinexcel' } ]; - const BATTERY_SN_PREFIX = 'PNR020125101'; - const BATTERY_SN_SUFFIX_LENGTH = 4; + const batterySnRefs = useRef<(HTMLInputElement | null)[]>([]); // Initialize battery data from props useEffect(() => { @@ -68,14 +66,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { if (props.values.batterySerialNumbers) { const serialNumbers = props.values.batterySerialNumbers .split(',') - .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; - }); + .filter((sn) => sn.trim() !== ''); setBatterySerialNumbers(serialNumbers); } }, []); @@ -107,41 +98,47 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { const value = inputValue === '' ? 0 : parseInt(inputValue); setBatteryNumber(value); - // Preserve existing serial numbers and adjust array size - const newSerialNumbers = Array.from({ length: value }, (_, index) => { - // Keep existing serial number if it exists, otherwise use empty string - return batterySerialNumbers[index] || ''; - }); - setBatterySerialNumbers(newSerialNumbers); + if (value > 0) { + // Resize array: preserve existing serial numbers, add empty for new slots + const newSerialNumbers = Array.from({ length: value }, (_, index) => { + return batterySerialNumbers[index] || ''; + }); + setBatterySerialNumbers(newSerialNumbers); - // Update formValues with preserved serial numbers - const fullSerialNumbers = newSerialNumbers - .map((suffix) => (suffix ? BATTERY_SN_PREFIX + suffix : '')) - .filter((sn) => sn !== ''); - - setFormValues({ - ...formValues, - batteryNumber: value, - batterySerialNumbers: fullSerialNumbers.join(',') - }); + setFormValues({ + ...formValues, + batteryNumber: value, + batterySerialNumbers: newSerialNumbers.filter((sn) => sn !== '').join(',') + }); + } else { + // Field is empty (user is mid-edit) — don't clear serial numbers + setFormValues({ + ...formValues, + batteryNumber: 0 + }); + } } }; 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]; - updatedSerialNumbers[index] = sanitizedValue; + updatedSerialNumbers[index] = value; 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({ ...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 = () => { setLoading(true); setError(false); @@ -467,20 +464,11 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { onChange={(e) => handleBatterySerialNumberChange(index, e.target.value) } + onKeyDown={(e) => handleBatterySnKeyDown(e, index)} + inputRef={(el) => (batterySnRefs.current[index] = el)} variant="outlined" fullWidth - InputProps={{ - startAdornment: ( - - {BATTERY_SN_PREFIX} - - ) - }} - inputProps={{ - maxLength: BATTERY_SN_SUFFIX_LENGTH, - placeholder: '0000' - }} - helperText={`Enter ${BATTERY_SN_SUFFIX_LENGTH} digits`} + placeholder="Scan or enter serial number" /> ))} diff --git a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx index b7c699e0e..8e0a4a249 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx @@ -118,6 +118,12 @@ function Overview(props: OverviewProps) { resultPromise .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) => prevData.concat({ chartData: result.chartData, @@ -281,6 +287,12 @@ function Overview(props: OverviewProps) { resultPromise .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) => prevData.concat({ chartData: result.chartData, @@ -511,20 +523,22 @@ function Overview(props: OverviewProps) { > - - - + {product !== 2 && ( + + + + )} {/*{aggregatedData && (*/} - + + {product !== 2 && ( + )} )} @@ -1336,7 +1352,7 @@ function Overview(props: OverviewProps) { alignItems="stretch" spacing={3} > - + + {product !== 2 && ( + )} )} diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx index d8bae8c68..e9adb8df0 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx @@ -26,6 +26,7 @@ import { FetchResult } from '../../../dataCache/dataCache'; import BatteryViewSodioHome from '../BatteryView/BatteryViewSodioHome'; import SodistoreHomeConfiguration from './SodistoreHomeConfiguration'; import TopologySodistoreHome from '../Topology/TopologySodistoreHome'; +import Overview from '../Overview/overview'; interface singleInstallationProps { current_installation?: I_Installation; @@ -182,11 +183,13 @@ function SodioHomeInstallation(props: singleInstallationProps) { }; const fetchDataForOneTime = async () => { - var timeperiodToSearch = 200; + var timeperiodToSearch = 300; // 5 minutes to cover ~4 upload cycles let res; 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)); try { res = await fetchDataJson(timestampToFetch, s3Credentials, false); @@ -199,7 +202,7 @@ function SodioHomeInstallation(props: singleInstallationProps) { } } - if (i <= 0) { + if (i >= timeperiodToSearch) { setConnected(false); setLoading(false); return false; @@ -207,8 +210,10 @@ function SodioHomeInstallation(props: singleInstallationProps) { setConnected(true); setLoading(false); - const timestamp = Object.keys(res)[Object.keys(res).length - 1]; - setValues(res[timestamp]); + // Sort timestamps numerically to ensure we get the most recent data point + const timestamps = Object.keys(res).sort((a, b) => Number(b) - Number(a)); + const latestTimestamp = timestamps[0]; + setValues(res[latestTimestamp]); // setValues( // extractValues({ // 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') { - 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 () => { @@ -527,6 +542,16 @@ function SodioHomeInstallation(props: singleInstallationProps) { /> )} + + } + /> + } diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx index 6aa7ad22a..cad3fcbbc 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/SodistoreHomeConfiguration.tsx @@ -16,7 +16,7 @@ import { useTheme } from '@mui/material'; -import React, { useContext, useState } from 'react'; +import React, { useContext, useState, useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; import Button from '@mui/material/Button'; import { Close as CloseIcon } from '@mui/icons-material'; @@ -105,6 +105,78 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { 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) => { setFormValues({ ...formValues, @@ -173,6 +245,13 @@ function SodistoreHomeConfiguration(props: SodistoreHomeConfigurationProps) { if (res) { setUpdated(true); 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() + })); } }; diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx index 65eced584..a538b196e 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx @@ -24,10 +24,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { const { currentUser } = context; const tabList = [ 'live', + 'overview', 'batteryview', 'information', 'manage', - 'overview', 'log', 'history', 'configuration' @@ -100,6 +100,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { value: 'live', label: }, + { + value: 'overview', + label: + }, { value: 'batteryview', label: ( @@ -109,10 +113,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { /> ) }, - // { - // value: 'overview', - // label: - // }, { value: 'log', label: @@ -159,11 +159,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { value: 'live', label: }, - // { - // value: 'overview', - // label: - // }, - + { + value: 'overview', + label: + }, { value: 'information', label: ( @@ -190,6 +189,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { value: 'live', label: }, + { + value: 'overview', + label: + }, { value: 'batteryview', label: ( @@ -199,10 +202,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { /> ) }, - // { - // value: 'overview', - // label: - // }, { value: 'log', label: diff --git a/typescript/frontend-marios2/src/interfaces/Chart.tsx b/typescript/frontend-marios2/src/interfaces/Chart.tsx index 1311d89de..caffc231e 100644 --- a/typescript/frontend-marios2/src/interfaces/Chart.tsx +++ b/typescript/frontend-marios2/src/interfaces/Chart.tsx @@ -60,6 +60,7 @@ export interface BatteryDataInterface { Power: { name: string; data: [] }; Voltage: { name: string; data: [] }; Current: { name: string; data: [] }; + Soh?: { name: string; data: [] }; } export interface BatteryOverviewInterface { @@ -68,6 +69,7 @@ export interface BatteryOverviewInterface { Power: chartInfoInterface; Voltage: chartInfoInterface; Current: chartInfoInterface; + Soh?: chartInfoInterface; } export const transformInputToBatteryViewDataJson = async ( @@ -75,14 +77,18 @@ export const transformInputToBatteryViewDataJson = async ( id: number, product: number, start_time?: UnixTime, - end_time?: UnixTime + end_time?: UnixTime, + batteryClusterNumber?: number ): Promise<{ chartData: BatteryDataInterface; chartOverview: BatteryOverviewInterface; }> => { const prefixes = ['', 'k', 'M', 'G', 'T']; 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 = product === 3 ? [ @@ -120,7 +126,8 @@ export const transformInputToBatteryViewDataJson = async ( Temperature: { name: 'Temperature', data: [] }, Power: { name: 'Power', data: [] }, Voltage: { name: 'Voltage', data: [] }, - Current: { name: 'Current', data: [] } + Current: { name: 'Current', data: [] }, + ...(isSodioHome && { Soh: { name: 'State Of Health', data: [] } }) }; const chartOverview: BatteryOverviewInterface = { @@ -128,7 +135,8 @@ export const transformInputToBatteryViewDataJson = async ( Temperature: { magnitude: 0, unit: '', min: 0, max: 0 }, Power: { 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; @@ -159,7 +167,7 @@ export const transformInputToBatteryViewDataJson = async ( ); const adjustedTimestamp = - product == 0 || product == 3 + product == 0 || product == 2 || product == 3 ? new Date(timestampArray[i] * 1000) : new Date(timestampArray[i] * 100000); //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]; - //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 - let old_length = pathsToSave.length; + if (isSodioHome) { + // SodistoreHome: extract battery data from InverterRecord + const inv = (result as any)?.InverterRecord; + if (!inv) continue; - 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); - } - }); - } + const numBatteries = batteryClusterNumber || 1; + let old_length = pathsToSave.length; - // console.log(pathsToSave); - - 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: [] }; + if (numBatteries > old_length) { + for (let b = old_length; b < numBatteries; b++) { + const nodeName = 'Node' + b; + if (!pathsToSave.includes(nodeName)) { + pathsToSave.push(nodeName); } - }); - }); - } + } + } - for ( - let category_index = 0; - category_index < pathCategories.length; - category_index++ - ) { - let category = categories[category_index]; + if (initialiation) { + initialiation = false; + categories.forEach((category) => { + chartData[category].data = []; + chartOverview[category] = { + 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++) { - 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) { - const value = path - .split('.') - .reduce((o, key) => (o ? o[key] : undefined), result); - - if (value < chartOverview[category].min) { - chartOverview[category].min = value; + if (value !== undefined && value !== null) { + 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 + ]); } + }); + } + } else { + // SaliMax, Salidomo, SodistoreMax: existing logic + const battery_nodes = + result.Config.Devices.BatteryNodes.toString().split(','); - if (value > chartOverview[category].max) { - chartOverview[category].max = value; + //Initialize the chartData structure based on the node names extracted from the first result + 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.min = 0; chartOverview.Soc.max = 100; - chartOverview.Temperature.unit = '(°C)'; + if (!isSodioHome) { + chartOverview.Temperature.unit = '(°C)'; + } chartOverview.Power.unit = '(' + prefixes[chartOverview['Power'].magnitude] + 'W' + ')'; chartOverview.Voltage.unit = '(' + prefixes[chartOverview['Voltage'].magnitude] + 'V' + ')'; - chartOverview.Current.unit = '(' + prefixes[chartOverview['Current'].magnitude] + 'A' + ')'; - - // console.log(chartData); + if (isSodioHome) { + chartOverview.Soh.unit = '(%)'; + chartOverview.Soh.min = 0; + chartOverview.Soh.max = 100; + } return { chartData: chartData, @@ -310,21 +387,31 @@ export const transformInputToDailyDataJson = async ( const prefixes = ['', 'k', 'M', 'G', 'T']; const MAX_NUMBER = 9999999; - const pathsToSearch = [ - 'Battery.Soc', - product == 0 ? 'Battery.Temperature' : 'Battery.TemperatureCell1', - - //'Battery.Temperature' for salimax, - //'Battery.TemperatureCell1', - product == 0 ? 'Battery.Dc.Power' : 'Battery.Power', - //'Battery.Dc.Power' for salimax, - // 'Battery.Power', - 'GridMeter.Ac.Power.Active', - 'PvOnDc', - 'DcDc.Dc.Link.Voltage', - 'LoadOnAcGrid.Power.Active', - 'LoadOnDc.Power' - ]; + // For SodioHome (product=2), paths are placeholders — actual extraction uses + // custom fallback logic to handle differences between Growatt and Sinexcel. + // Growatt has: Battery1AmbientTemperature, GridPower, PvPower + // Sinexcel has: Battery1Temperature, TotalGridPower (meter may be offline), PvPower1-4 + const pathsToSearch = product == 2 + ? [ + 'SODIOHOME_SOC', + 'SODIOHOME_TEMPERATURE', + 'SODIOHOME_BATTERY_POWER', + 'SODIOHOME_GRID_POWER', + 'SODIOHOME_PV_POWER', + null, // dcBusVoltage not available for SodioHome + 'SODIOHOME_CONSUMPTION', + 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 = [ 'soc', 'temperature', @@ -419,37 +506,78 @@ export const transformInputToDailyDataJson = async ( let category_index = 0; // eslint-disable-next-line @typescript-eslint/no-loop-func pathsToSearch.forEach((path) => { - if (get(result, path) !== undefined) { - let value: number | undefined = undefined; + if (path === null) { + // Skip unavailable fields (e.g. dcBusVoltage, DCLoad for SodioHome) + category_index++; + return; + } - if (category_index === 4) { - // Custom logic for 'PvOnDc.Dc.Power' + let value: number | undefined = undefined; + + 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( result.PvOnDc as Record ).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++; }); }