diff --git a/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryView.tsx b/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryView.tsx index 62a780dc4..1b10000ce 100644 --- a/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryView.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryView.tsx @@ -45,14 +45,36 @@ function BatteryView(props: BatteryViewProps) { const navigate = useNavigate(); const { product, setProduct } = useContext(ProductIdContext); - const sortedBatteryView = - props.values != null && props.values?.Battery?.Devices - ? Object.entries(props.values.Battery.Devices) - .map(([BatteryId, battery]) => { - return { BatteryId, battery }; // Here we return an object with the id and device - }) - .sort((a, b) => parseInt(b.BatteryId) - parseInt(a.BatteryId)) - : []; + // SodiStoreGrid (product 4) keeps batteries under ListOfBatteriesRecord[cluster].Devices[id]. + // Flatten into a single list with composite IDs "{cluster}-{device}" so the existing + // BatteryView table renders without further changes. + const sortedBatteryView = (() => { + if (props.values == null) return []; + + if ( + product === 4 && + props.values.ListOfBatteriesRecord + ) { + const flat: { BatteryId: string; battery: any }[] = []; + Object.entries(props.values.ListOfBatteriesRecord).forEach( + ([clusterId, cluster]: [string, any]) => { + if (cluster?.Devices) { + Object.entries(cluster.Devices).forEach(([devId, dev]) => { + flat.push({ BatteryId: `${clusterId}-${devId}`, battery: dev }); + }); + } + } + ); + return flat.sort((a, b) => a.BatteryId.localeCompare(b.BatteryId)); + } + + if (props.values?.Battery?.Devices) { + return Object.entries(props.values.Battery.Devices) + .map(([BatteryId, battery]) => ({ BatteryId, battery })) + .sort((a, b) => parseInt(b.BatteryId) - parseInt(a.BatteryId)); + } + return []; + })(); const [loading, setLoading] = useState(sortedBatteryView.length == 0); @@ -177,39 +199,37 @@ function BatteryView(props: BatteryViewProps) { } /> {product === 0 - ? Object.entries(props.values.Battery.Devices).map( - ([BatteryId, battery]) => ( - - } - /> - ) + ? sortedBatteryView.map(({ BatteryId, battery }) => ( + + } + /> + )) + : sortedBatteryView.map(({ BatteryId, battery }) => ( + + } + /> ) - : Object.entries(props.values.Battery.Devices).map( - ([BatteryId, battery]) => ( - - } - /> - ) )} @@ -262,7 +282,7 @@ function BatteryView(props: BatteryViewProps) { component="th" scope="row" align="center" - sx={{ fontWeight: 'bold' }} + sx={{ fontWeight: 'bold', whiteSpace: 'nowrap', minWidth: '100px' }} > { - if ( - Object.hasOwnProperty.call(chartData[category].data, devicePath) && - chartData[category].data[devicePath].data.length != 0 - ) { + presentPaths.forEach((devicePath) => { + if (chartData[category].data[devicePath]?.data?.length) { + const palette = + color === 'blue' + ? blueColors + : color === 'red' + ? redColors + : orangeColors; series.push({ ...chartData[category].data[devicePath], - color: - color === 'blue' - ? blueColors[i] - : color === 'red' - ? redColors[i] - : orangeColors[i] + color: palette[i % palette.length] }); } i++; diff --git a/typescript/frontend-marios2/src/content/dashboards/Configuration/ConfigurationSodistoreGrid.tsx b/typescript/frontend-marios2/src/content/dashboards/Configuration/ConfigurationSodistoreGrid.tsx new file mode 100644 index 000000000..4d101e817 --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/Configuration/ConfigurationSodistoreGrid.tsx @@ -0,0 +1,256 @@ +import { ConfigurationValues, JSONRecordData } from '../Log/graph.util'; +import { + Alert, + Box, + CardContent, + CircularProgress, + Container, + Grid, + IconButton, + TextField, + useTheme +} from '@mui/material'; + +import React, { useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import Button from '@mui/material/Button'; +import { Close as CloseIcon } from '@mui/icons-material'; +import axiosConfig from '../../../Resources/axiosConfig'; + +interface ConfigurationSodistoreGridProps { + values: JSONRecordData; + id: number; +} + +function ConfigurationSodistoreGrid(props: ConfigurationSodistoreGridProps) { + const intl = useIntl(); + if (props.values === null) { + return null; + } + + const theme = useTheme(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [updated, setUpdated] = useState(false); + + const [errors, setErrors] = useState({ + minimumSoC: false, + gridSetPoint: false + }); + + const SetErrorForField = (field_name: string, state: boolean) => { + setErrors((prevErrors) => ({ + ...prevErrors, + [field_name]: state + })); + }; + + const [formValues, setFormValues] = useState>({ + minimumSoC: props.values.Config?.MinSoc, + gridSetPoint: + props.values.Config?.GridSetPoint != null + ? (props.values.Config.GridSetPoint as number) / 1000 + : undefined + }); + + const handleChange = (e) => { + const { name, value } = e.target; + + switch (name) { + case 'minimumSoC': + if ( + /[^0-9.]/.test(value) || + isNaN(parseFloat(value)) || + parseFloat(value) > 100 + ) { + SetErrorForField(name, true); + } else { + SetErrorForField(name, false); + } + break; + case 'gridSetPoint': + if (/[^0-9.]/.test(value) || isNaN(parseFloat(value))) { + SetErrorForField(name, true); + } else { + SetErrorForField(name, false); + } + break; + default: + return true; + } + + setFormValues({ + ...formValues, + [name]: value + }); + }; + + const handleSubmit = async () => { + const configurationToSend: Partial = { + minimumSoC: formValues.minimumSoC, + gridSetPoint: formValues.gridSetPoint + }; + + setLoading(true); + const res = await axiosConfig + .post( + `/EditInstallationConfig?installationId=${props.id}&product=4`, + configurationToSend + ) + .catch((err) => { + if (err.response) { + setError(true); + setLoading(false); + } + }); + + if (res) { + setUpdated(true); + setLoading(false); + } + }; + + return ( + + + + + +
+ + } + name="minimumSoC" + value={formValues.minimumSoC ?? ''} + onChange={handleChange} + helperText={ + errors.minimumSoC ? ( + + {intl.formatMessage({ id: 'valueBetween0And100' })} + + ) : ( + '' + ) + } + fullWidth + /> +
+ +
+ + } + name="gridSetPoint" + value={formValues.gridSetPoint ?? ''} + onChange={handleChange} + helperText={ + errors.gridSetPoint ? ( + + {intl.formatMessage({ id: 'pleaseProvideValidNumber' })} + + ) : ( + '' + ) + } + fullWidth + /> +
+ +
+ + + {loading && ( + + )} + + {updated && ( + + + setUpdated(false)} + sx={{ marginLeft: '4px' }} + > + + + + )} + + {error && ( + + + setError(false)} + sx={{ marginLeft: '4px' }} + > + + + + )} +
+
+
+
+
+
+ ); +} + +export default ConfigurationSodistoreGrid; diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx index 3aae45443..1e423f162 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx @@ -25,9 +25,10 @@ import Information from '../Information/Information'; import { UserType } from '../../../interfaces/UserTypes'; import HistoryOfActions from '../History/History'; import Topology from '../Topology/Topology'; -import TopologySodistoreHome from '../Topology/TopologySodistoreHome'; +import TopologySodistoreGrid from '../Topology/TopologySodistoreGrid'; import BatteryView from '../BatteryView/BatteryView'; import Configuration from '../Configuration/Configuration'; +import ConfigurationSodistoreGrid from '../Configuration/ConfigurationSodistoreGrid'; import PvView from '../PvView/PvView'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; import DocumentsTab from '../Documents/DocumentsTab'; @@ -51,6 +52,9 @@ function Installation(props: singleInstallationProps) { const [currentTab, setCurrentTab] = useState(undefined); const [values, setValues] = useState(null); const status = props.current_installation.status; + // For SodiStoreGrid (product 4), backend heartbeat path is broken (SinexcelCommunication + // hardcodes Product=2), so installation.status stays -1 even when S3 is fresh. + // Treat as Green when our S3 fetch succeeded (connected === true). const [connected, setConnected] = useState(true); const [loading, setLoading] = useState(true); @@ -80,11 +84,61 @@ function Installation(props: singleInstallationProps) { //While fetching, we check the value of continueFetching, if its false, we break. This means that either the user changed tab or the object has been finished its execution (return) const continueFetching = useRef(false); + // SodiStoreGrid (Sinexcel) uploads chunked files every ~150s with arbitrary + // unix-second filenames (read key denies LIST — only GET is allowed). + // Probe a wide window in parallel batches with 1-second step. + // `checkContinue` lets the loop short-circuit when the user leaves Live, + // bounding wasted in-flight GETs to a single 20-request batch. + const probeLatestGridChunk = async ( + maxAgeSeconds: number = 600, + checkContinue: () => boolean = () => true + ): Promise<{ res: any; ts: any } | null> => { + const batchSize = 20; + const step = 1; + for (let batchStart = 0; batchStart < maxAgeSeconds; batchStart += batchSize * step) { + if (!checkContinue()) return null; + const offsets: number[] = []; + for (let j = 0; j < batchSize; j++) { + const offset = batchStart + j * step; + if (offset < maxAgeSeconds) offsets.push(offset); + } + const now = UnixTime.now(); + const results = await Promise.all( + offsets.map(async (offset) => { + const ts = now.earlier(TimeSpan.fromSeconds(offset)); + const r = await fetchDataJson(ts, s3Credentials, false); + return r !== FetchResult.notAvailable && r !== FetchResult.tryLater + ? { res: r, ts } + : null; + }) + ); + const hit = results.find((r) => r !== null); + if (hit) return hit; + } + return null; + }; + const fetchDataForOneTime = async () => { var timeperiodToSearch = 70; let res; let timestampToFetch; + if (props.current_installation.product === 4) { + const hit = await probeLatestGridChunk(600); + if (!hit) { + setConnected(false); + setLoading(false); + return false; + } + setConnected(true); + setLoading(false); + const timestamps = Object.keys(hit.res).sort( + (a, b) => Number(a) - Number(b) + ); + setValues(hit.res[timestamps[timestamps.length - 1]]); + return true; + } + for (var i = timeperiodToSearch; i > 0; i -= 2) { timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i)); try { @@ -122,6 +176,55 @@ function Installation(props: singleInstallationProps) { let res; let timestampToFetch; + // SodiStoreGrid: probe a wide window in parallel (read key denies LIST), + // stream through that chunk's timestamps, then refresh. + // Backoff schedule on consecutive misses to cap S3 cost on offline installs. + if (props.current_installation.product === 4) { + let firstHit = false; + let consecutiveMisses = 0; + const backoffMs = [30000, 60000, 120000, 300000]; // 30s → 60s → 2m → 5m (cap) + while (continueFetching.current) { + // Narrow window after first hit; widen back if we lose it. + const window = firstHit && consecutiveMisses === 0 ? 200 : 600; + const hit = await probeLatestGridChunk( + window, + () => continueFetching.current + ); + if (!continueFetching.current) break; + if (!hit) { + consecutiveMisses += 1; + // Always reflect disconnection in the UI — even after a prior hit, + // so the user sees a stale-data signal instead of a frozen chart. + setConnected(false); + if (!firstHit) setLoading(false); + const wait = backoffMs[Math.min(consecutiveMisses - 1, backoffMs.length - 1)]; + await timeout(wait); + continue; + } + consecutiveMisses = 0; + if (!firstHit) { + firstHit = true; + setLoading(false); + } + setConnected(true); + // Stream through chunk timestamps in ascending order (chunk = ~15 records, ~10s apart) + const orderedTs = Object.keys(hit.res).sort( + (a, b) => Number(a) - Number(b) + ); + for (const t of orderedTs) { + if (!continueFetching.current) { + setFetchFunctionCalled(false); + return false; + } + setValues(hit.res[t]); + await timeout(2000); + } + await timeout(30000); + } + setFetchFunctionCalled(false); + return false; + } + for (var i = 0; i < timeperiodToSearch; i += 2) { if (!continueFetching.current) { return false; @@ -194,12 +297,14 @@ function Installation(props: singleInstallationProps) { setCurrentTab(path[path.length - 1]); }, [location]); - //If status is -1, the topology will be empty and "Unable to communicate with the installation" should be rendered from the Topology component + //If status is -1, the topology will be empty and "Unable to communicate with the installation" should be rendered from the Topology component. + //SodiStoreGrid (product 4) is excluded: backend heartbeat path is broken + //because SinexcelCommunication hardcodes Product=2 — trust S3 freshness instead. useEffect(() => { - if (status === -1) { + if (status === -1 && props.current_installation.product !== 4) { setConnected(false); } - }, [status]); + }, [status, props.current_installation.product]); useEffect(() => { if ( @@ -276,7 +381,7 @@ function Installation(props: singleInstallationProps) { - {currentTab == 'live' && values && ( + {currentTab == 'live' && values && values.EssControl?.Mode && (
- {status === -1 ? ( + {status === -1 && !(props.current_installation.product === 4 && connected) ? ( + > ) : ( - - Configuration not yet available - - + ) : ( { 'InverterRecord.TotalBatteryPower', 'InverterRecord.TotalPhotovoltaicPower', 'InverterRecord.TotalLoadPower', - 'InverterRecord.TotalGridPower' + 'InverterRecord.TotalGridPower', + 'InverterRecord.ActivePowerW', + 'DcDc.Dc.Battery.Power' ]; // Helper function to safely get a value from a nested path diff --git a/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx b/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx index a44863d5c..84bea55c0 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx @@ -7,7 +7,9 @@ export const getChartOptions = ( type: string, dateList: string[], stacked: Boolean, - voltageInfo?: chartInfoInterface + voltageInfo?: chartInfoInterface, + powerLabel?: string, + temperatureInfo?: chartInfoInterface ): ApexOptions => { return type.includes('daily') ? { @@ -57,7 +59,7 @@ export const getChartOptions = ( type === 'dailyoverview' ? [ { - seriesName: 'Grid Power', + seriesName: powerLabel ?? 'Grid Power', tickAmount: 6, min: chartInfo.min >= 0 @@ -94,7 +96,7 @@ export const getChartOptions = ( } }, { - seriesName: 'Grid Power', + seriesName: powerLabel ?? 'Grid Power', show: false, tickAmount: 6, min: @@ -123,7 +125,7 @@ export const getChartOptions = ( } }, { - seriesName: 'Grid Power', + seriesName: powerLabel ?? 'Grid Power', show: false, tickAmount: 6, min: @@ -192,6 +194,27 @@ export const getChartOptions = ( return Math.round(value).toString(); } } + }] : []), + ...(temperatureInfo ? [{ + seriesName: 'Battery Temperature', + opposite: true, + tickAmount: 5, + min: Math.floor((temperatureInfo.min - 5) / 5) * 5, + max: Math.ceil((temperatureInfo.max + 5) / 5) * 5, + title: { + text: '(°C)', + style: { + fontSize: '12px' + }, + offsetY: -190, + offsetX: -65, + rotate: 0 + }, + labels: { + formatter: function (value: number) { + return Math.round(value).toString(); + } + } }] : []) ] : { @@ -241,18 +264,27 @@ export const getChartOptions = ( }, y: { formatter: function (val, { seriesIndex, w }) { + // `shared: true` calls this for every series at the hovered x, + // even when a particular series has no data point there → val undefined. + if (val === undefined || val === null || Number.isNaN(val)) { + return '-'; + } const seriesName = w.config.series[seriesIndex].name; if (seriesName === 'Battery SOC') { return val.toFixed(2) + ' %'; } else if (seriesName === 'Battery Voltage') { return val.toFixed(2) + ' (V)'; + } else if (seriesName === 'Battery Temperature') { + return val.toFixed(2) + ' (°C)'; } else { + const formatted = formatPowerForGraph(val, chartInfo.magnitude); + const raw = formatted?.value; return ( - formatPowerForGraph(val, chartInfo.magnitude).value.toFixed( - 2 - ) + + (raw === undefined || raw === null || Number.isNaN(raw) + ? '-' + : raw.toFixed(2)) + ' ' + - chartInfo.unit + (chartInfo.unit ?? '') ); } } diff --git a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx index 1854b361e..35f85c0f8 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx @@ -566,7 +566,7 @@ function Overview(props: OverviewProps) { > - {props.device !== 3 && props.product !== 2 && ( + {props.device !== 3 && props.product !== 2 && props.product !== 4 && (