Compare commits
No commits in common. "646b6c0e208e66e2458bed6b0a21a4001dc0cec5" and "4fa5ba60c8ea4a1da91401812d840ae9ba0e6d9a" have entirely different histories.
646b6c0e20
...
4fa5ba60c8
|
|
@ -28,13 +28,6 @@ public class Configuration
|
|||
public DateTime? StartTimeChargeandDischargeDayandTime { get; set; }
|
||||
public DateTime? StopTimeChargeandDischargeDayandTime { get; set; }
|
||||
|
||||
// SodistoreGrid: inverter battery-limit settings (Sinexcel) — surfaced on Configuration tab.
|
||||
public double? ActivePowerPercent { get; set; }
|
||||
public double? MinDischargeVoltage { get; set; }
|
||||
public double? MaxDischargeCurrent { get; set; }
|
||||
public double? MaxChargeCurrent { get; set; }
|
||||
public double? MaxChargeVoltage { get; set; }
|
||||
|
||||
// Sinexcel Dynamic Pricing (under GridPriority) — strings for demo; engine will parse later.
|
||||
public string? DynamicPricingMode { get; set; }
|
||||
public string? NetworkProvider { get; set; }
|
||||
|
|
@ -47,13 +40,6 @@ public class Configuration
|
|||
public string? TimeToBuyFrom { get; set; }
|
||||
public string? TimeToBuyTo { get; set; }
|
||||
|
||||
// Relay control (Sinexcel only, device=4) — surfaced on Configuration tab for SodistoreHome + SodistorePro.
|
||||
// Nullable so WhenWritingNull keeps them out of payloads for non-Sinexcel installations.
|
||||
public bool? Relay1 { get; set; }
|
||||
public bool? Relay2 { get; set; }
|
||||
public bool? Relay3 { get; set; }
|
||||
public bool? Relay4 { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public enum CalibrationChargeType
|
||||
|
|
|
|||
|
|
@ -45,36 +45,14 @@ function BatteryView(props: BatteryViewProps) {
|
|||
const navigate = useNavigate();
|
||||
const { product, setProduct } = useContext(ProductIdContext);
|
||||
|
||||
// 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 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))
|
||||
: [];
|
||||
|
||||
const [loading, setLoading] = useState(sortedBatteryView.length == 0);
|
||||
|
||||
|
|
@ -199,7 +177,8 @@ function BatteryView(props: BatteryViewProps) {
|
|||
}
|
||||
/>
|
||||
{product === 0
|
||||
? sortedBatteryView.map(({ BatteryId, battery }) => (
|
||||
? Object.entries(props.values.Battery.Devices).map(
|
||||
([BatteryId, battery]) => (
|
||||
<Route
|
||||
key={routes.detailed_view + BatteryId}
|
||||
path={routes.detailed_view + BatteryId}
|
||||
|
|
@ -213,15 +192,16 @@ function BatteryView(props: BatteryViewProps) {
|
|||
></DetailedBatteryView>
|
||||
}
|
||||
/>
|
||||
))
|
||||
: sortedBatteryView.map(({ BatteryId, battery }) => (
|
||||
)
|
||||
)
|
||||
: Object.entries(props.values.Battery.Devices).map(
|
||||
([BatteryId, battery]) => (
|
||||
<Route
|
||||
key={routes.detailed_view + BatteryId}
|
||||
path={routes.detailed_view + BatteryId}
|
||||
element={
|
||||
<DetailedBatteryViewSodistore
|
||||
// Keep BatteryId as-is (Number("1-1") === NaN for product 4).
|
||||
batteryId={BatteryId}
|
||||
batteryId={Number(BatteryId)}
|
||||
s3Credentials={props.s3Credentials}
|
||||
batteryData={battery}
|
||||
installationId={props.installationId}
|
||||
|
|
@ -282,7 +262,7 @@ function BatteryView(props: BatteryViewProps) {
|
|||
component="th"
|
||||
scope="row"
|
||||
align="center"
|
||||
sx={{ fontWeight: 'bold', whiteSpace: 'nowrap', minWidth: '100px' }}
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
>
|
||||
<Link
|
||||
style={{ color: 'black' }}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|||
import GaugeChart from 'react-gauge-chart';
|
||||
|
||||
interface DetailedBatteryViewSodistoreProps {
|
||||
// SodistoreGrid uses composite "{cluster}-{device}" IDs (e.g. "1-1"),
|
||||
// so this is intentionally widened — the value is only rendered, never Number()'d.
|
||||
batteryId: number | string;
|
||||
batteryId: number;
|
||||
s3Credentials: I_S3Credentials;
|
||||
batteryData: Device;
|
||||
installationId: number;
|
||||
|
|
|
|||
|
|
@ -134,22 +134,34 @@ function MainStats(props: MainStatsProps) {
|
|||
|
||||
function generateSeries(chartData, category, color) {
|
||||
const series = [];
|
||||
// Use all actually-present series keys so product 4 (SodiStoreGrid)
|
||||
// composite names like "Node1-1".."Node3-6" are picked up too.
|
||||
const presentPaths = Object.keys(chartData[category]?.data ?? {}).sort();
|
||||
const pathsToSearch = [
|
||||
'Node0',
|
||||
'Node1',
|
||||
'Node2',
|
||||
'Node3',
|
||||
'Node4',
|
||||
'Node5',
|
||||
'Node6',
|
||||
'Node7',
|
||||
'Node8',
|
||||
'Node9',
|
||||
'Node10'
|
||||
];
|
||||
|
||||
let i = 0;
|
||||
presentPaths.forEach((devicePath) => {
|
||||
if (chartData[category].data[devicePath]?.data?.length) {
|
||||
const palette =
|
||||
color === 'blue'
|
||||
? blueColors
|
||||
: color === 'red'
|
||||
? redColors
|
||||
: orangeColors;
|
||||
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: palette[i % palette.length]
|
||||
color:
|
||||
color === 'blue'
|
||||
? blueColors[i]
|
||||
: color === 'red'
|
||||
? redColors[i]
|
||||
: orangeColors[i]
|
||||
});
|
||||
}
|
||||
i++;
|
||||
|
|
|
|||
|
|
@ -1,279 +0,0 @@
|
|||
import { 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;
|
||||
}
|
||||
|
||||
// Wire-format keys (camelCase → backend Configuration PascalCase via case-insensitive bind).
|
||||
interface GridFormValues {
|
||||
minimumSoC?: string;
|
||||
activePowerPercent?: string;
|
||||
minDischargeVoltage?: string;
|
||||
maxDischargeCurrent?: string;
|
||||
maxChargeCurrent?: string;
|
||||
maxChargeVoltage?: string;
|
||||
}
|
||||
|
||||
// Per-field validation rules.
|
||||
// type="text" + regex (NOT type="number" — avoids spinner & locale issues, per project rule).
|
||||
const FIELDS: {
|
||||
name: keyof GridFormValues;
|
||||
labelId: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
allowNegative?: boolean;
|
||||
}[] = [
|
||||
{ name: 'minimumSoC', labelId: 'minimumSocPercent', min: 0, max: 100 },
|
||||
{ name: 'activePowerPercent', labelId: 'activePowerPercent', min: -100, max: 100, allowNegative: true },
|
||||
{ name: 'minDischargeVoltage', labelId: 'minDischargeVoltageV', min: 0 },
|
||||
{ name: 'maxDischargeCurrent', labelId: 'maxDischargeCurrentA', min: 0 },
|
||||
{ name: 'maxChargeCurrent', labelId: 'maxChargeCurrentA', min: 0 },
|
||||
{ name: 'maxChargeVoltage', labelId: 'maxChargeVoltageV', min: 0 }
|
||||
];
|
||||
|
||||
function ConfigurationSodistoreGrid(props: ConfigurationSodistoreGridProps) {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
|
||||
// All hooks must be called unconditionally (Rules of Hooks). The null-check
|
||||
// moved BELOW the hook declarations.
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [updated, setUpdated] = useState(false);
|
||||
|
||||
// Lazy initializer reads from props.values safely even when null.
|
||||
const [formValues, setFormValues] = useState<GridFormValues>(() => {
|
||||
const inv: any = props.values?.InverterRecord ?? {};
|
||||
const cfg: any = props.values?.Config ?? {};
|
||||
return {
|
||||
minimumSoC: cfg.MinSoc != null ? String(cfg.MinSoc) : '',
|
||||
activePowerPercent: cfg.ActivePowerPercent != null ? String(cfg.ActivePowerPercent) : '',
|
||||
minDischargeVoltage: inv.MinDischargeVoltage != null ? String(inv.MinDischargeVoltage) : '',
|
||||
maxDischargeCurrent: inv.MaxDischargeCurrent != null ? String(inv.MaxDischargeCurrent) : '',
|
||||
maxChargeCurrent: inv.MaxChargeCurrent != null ? String(inv.MaxChargeCurrent) : '',
|
||||
maxChargeVoltage: inv.MaxChargeVoltage != null ? String(inv.MaxChargeVoltage) : ''
|
||||
};
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<keyof GridFormValues, boolean>>({
|
||||
minimumSoC: false,
|
||||
activePowerPercent: false,
|
||||
minDischargeVoltage: false,
|
||||
maxDischargeCurrent: false,
|
||||
maxChargeCurrent: false,
|
||||
maxChargeVoltage: false
|
||||
});
|
||||
|
||||
if (props.values === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validate = (
|
||||
field: (typeof FIELDS)[number],
|
||||
raw: string
|
||||
): boolean => {
|
||||
if (raw.trim() === '') return false; // empty = "leave unchanged", not error
|
||||
// Regex: allow leading minus only if field accepts negatives, optional digits + dot
|
||||
const pattern = field.allowNegative ? /^-?\d*\.?\d*$/ : /^\d*\.?\d*$/;
|
||||
if (!pattern.test(raw)) return true;
|
||||
const n = parseFloat(raw);
|
||||
if (isNaN(n)) return true;
|
||||
if (field.min !== undefined && n < field.min) return true;
|
||||
if (field.max !== undefined && n > field.max) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
const field = FIELDS.find((f) => f.name === name);
|
||||
if (!field) return;
|
||||
|
||||
setErrors((prev) => ({ ...prev, [name]: validate(field, value) }));
|
||||
setFormValues((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const anyError = Object.values(errors).some(Boolean);
|
||||
const allEmpty = Object.values(formValues).every(
|
||||
(v) => !v || v.trim() === ''
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Only send fields the user actually entered. Empty string = skip.
|
||||
const payload: Record<string, number> = {};
|
||||
FIELDS.forEach((f) => {
|
||||
const raw = formValues[f.name];
|
||||
if (raw && raw.trim() !== '') {
|
||||
const n = parseFloat(raw);
|
||||
if (!isNaN(n)) payload[f.name] = n;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setUpdated(false);
|
||||
|
||||
try {
|
||||
await axiosConfig.post(
|
||||
`/EditInstallationConfig?installationId=${props.id}&product=4`,
|
||||
payload
|
||||
);
|
||||
setUpdated(true);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const helperFor = (name: keyof GridFormValues, hasError: boolean) => {
|
||||
if (!hasError) return '';
|
||||
const f = FIELDS.find((fld) => fld.name === name)!;
|
||||
// Range-aware helper: 0–100 → existing key; otherwise generic.
|
||||
if (f.min === 0 && f.max === 100) {
|
||||
return (
|
||||
<span style={{ color: 'red' }}>
|
||||
{intl.formatMessage({ id: 'valueBetween0And100' })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span style={{ color: 'red' }}>
|
||||
{intl.formatMessage({ id: 'pleaseProvideValidNumber' })}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl">
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
alignItems="stretch"
|
||||
spacing={3}
|
||||
>
|
||||
<Grid item xs={12} md={12}>
|
||||
<CardContent>
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ '& .MuiTextField-root': { m: 1, width: 390 } }}
|
||||
noValidate
|
||||
autoComplete="off"
|
||||
onSubmit={(e) => {
|
||||
// Prevent native form submit (Enter in TextField would navigate).
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
{FIELDS.map((f) => (
|
||||
<div key={f.name} style={{ marginBottom: '5px' }}>
|
||||
<TextField
|
||||
type="text"
|
||||
label={<FormattedMessage id={f.labelId} />}
|
||||
name={f.name}
|
||||
value={formValues[f.name] ?? ''}
|
||||
onChange={handleChange}
|
||||
error={errors[f.name]}
|
||||
helperText={helperFor(f.name, errors[f.name])}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginTop: 10
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={anyError || loading || allEmpty}
|
||||
sx={{ marginLeft: '10px' }}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="applyChanges"
|
||||
defaultMessage="Apply changes"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{loading && (
|
||||
<CircularProgress
|
||||
sx={{
|
||||
color: theme.palette.primary.main,
|
||||
marginLeft: '20px'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{updated && (
|
||||
<Alert
|
||||
severity="success"
|
||||
sx={{ ml: 1, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="successfullyAppliedConfig"
|
||||
defaultMessage="Successfully applied configuration file"
|
||||
/>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => setUpdated(false)}
|
||||
sx={{ marginLeft: '4px' }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ ml: 1, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="configErrorOccurred"
|
||||
defaultMessage="An error has occurred"
|
||||
/>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => setError(false)}
|
||||
sx={{ marginLeft: '4px' }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigurationSodistoreGrid;
|
||||
|
|
@ -924,9 +924,6 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
<MenuItem value="DC">
|
||||
<FormattedMessage id="couplingDC" defaultMessage="DC-coupled" />
|
||||
</MenuItem>
|
||||
<MenuItem value="Mixed">
|
||||
<FormattedMessage id="couplingMixed" defaultMessage="Mixed" />
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,10 +25,9 @@ import Information from '../Information/Information';
|
|||
import { UserType } from '../../../interfaces/UserTypes';
|
||||
import HistoryOfActions from '../History/History';
|
||||
import Topology from '../Topology/Topology';
|
||||
import TopologySodistoreGrid from '../Topology/TopologySodistoreGrid';
|
||||
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
||||
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';
|
||||
|
|
@ -52,10 +51,6 @@ function Installation(props: singleInstallationProps) {
|
|||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
const [values, setValues] = useState<JSONRecordData | null>(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.
|
||||
// TODO: remove this override once SinexcelCommunication/Program.cs derives Product from runtime config.
|
||||
// Treat as Green when our S3 fetch succeeded (connected === true).
|
||||
const [connected, setConnected] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
|
@ -85,61 +80,11 @@ 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 {
|
||||
|
|
@ -177,55 +122,6 @@ 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;
|
||||
|
|
@ -298,14 +194,12 @@ 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.
|
||||
//SodiStoreGrid (product 4) is excluded: backend heartbeat path is broken
|
||||
//because SinexcelCommunication hardcodes Product=2 — trust S3 freshness instead.
|
||||
//If status is -1, the topology will be empty and "Unable to communicate with the installation" should be rendered from the Topology component
|
||||
useEffect(() => {
|
||||
if (status === -1 && props.current_installation.product !== 4) {
|
||||
if (status === -1) {
|
||||
setConnected(false);
|
||||
}
|
||||
}, [status, props.current_installation.product]);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
|
@ -382,7 +276,7 @@ function Installation(props: singleInstallationProps) {
|
|||
</Typography>
|
||||
</div>
|
||||
|
||||
{currentTab == 'live' && values && values.EssControl?.Mode && (
|
||||
{currentTab == 'live' && values && (
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography
|
||||
fontWeight="bold"
|
||||
|
|
@ -432,7 +326,7 @@ function Installation(props: singleInstallationProps) {
|
|||
marginTop: '-10px'
|
||||
}}
|
||||
>
|
||||
{status === -1 && !(props.current_installation.product === 4 && connected) ? (
|
||||
{status === -1 ? (
|
||||
<CancelIcon
|
||||
style={{
|
||||
width: '23px',
|
||||
|
|
@ -467,8 +361,7 @@ function Installation(props: singleInstallationProps) {
|
|||
? 'red'
|
||||
: status === 1
|
||||
? 'orange'
|
||||
: (status === -1 || status === -2) &&
|
||||
!(props.current_installation.product === 4 && connected)
|
||||
: status === -1 || status === -2
|
||||
? 'transparent'
|
||||
: 'green'
|
||||
}}
|
||||
|
|
@ -576,11 +469,12 @@ function Installation(props: singleInstallationProps) {
|
|||
path={routes.live}
|
||||
element={
|
||||
props.current_installation.product === 4 ? (
|
||||
<TopologySodistoreGrid
|
||||
<TopologySodistoreHome
|
||||
values={values}
|
||||
connected={connected}
|
||||
loading={loading}
|
||||
></TopologySodistoreGrid>
|
||||
batteryClusterNumber={props.current_installation.batteryClusterNumber}
|
||||
></TopologySodistoreHome>
|
||||
) : (
|
||||
<Topology
|
||||
values={values}
|
||||
|
|
@ -607,10 +501,20 @@ function Installation(props: singleInstallationProps) {
|
|||
path={routes.configuration}
|
||||
element={
|
||||
props.current_installation.product === 4 ? (
|
||||
<ConfigurationSodistoreGrid
|
||||
values={values}
|
||||
id={props.current_installation.id}
|
||||
/>
|
||||
// TODO: SodistoreGrid — implement actual configuration
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '40vh'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Configuration not yet available
|
||||
</Typography>
|
||||
</Container>
|
||||
) : (
|
||||
<Configuration
|
||||
values={values}
|
||||
|
|
|
|||
|
|
@ -106,9 +106,9 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
// TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready
|
||||
const hidePvView = props.product === 4;
|
||||
|
||||
// Checklist is not shown for any product in the Installations view.
|
||||
// Salimax (0) / Salidomo (1) / SodistoreMax (3) / SodistoreGrid (4) use different onboarding flows.
|
||||
const showChecklist = false;
|
||||
// Checklist is only shown for Sodistore Grid (product=4) in the Installations view.
|
||||
// Salimax (0) / Salidomo (1) / SodistoreMax (3) use different onboarding flows.
|
||||
const showChecklist = props.product === 4;
|
||||
|
||||
const singleInstallationTabs = (
|
||||
currentUser.userType == UserType.admin
|
||||
|
|
|
|||
|
|
@ -594,19 +594,6 @@ export interface JSONRecordData {
|
|||
// [PvId: string]: PvString;
|
||||
// };
|
||||
// };
|
||||
|
||||
// For SodistoreGrid: list of battery clusters keyed by 1-based string IDs
|
||||
ListOfBatteriesRecord?: {
|
||||
[clusterId: string]: {
|
||||
Soc: number;
|
||||
Soh: number;
|
||||
Voltage: number;
|
||||
Current: number;
|
||||
Power: number;
|
||||
TemperatureCell1: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const parseChunkJson = (
|
||||
|
|
@ -747,12 +734,6 @@ export type ConfigurationValues = {
|
|||
timeToSellTo?: string;
|
||||
timeToBuyFrom?: string;
|
||||
timeToBuyTo?: string;
|
||||
|
||||
// For SodistoreHome + SodistorePro Sinexcel (device=4): relay control toggles
|
||||
relay1?: boolean;
|
||||
relay2?: boolean;
|
||||
relay3?: boolean;
|
||||
relay4?: boolean;
|
||||
};
|
||||
//
|
||||
// export interface Pv {
|
||||
|
|
@ -1193,9 +1174,7 @@ export const getHighestConnectionValue = (values: JSONRecordData) => {
|
|||
'InverterRecord.TotalBatteryPower',
|
||||
'InverterRecord.TotalPhotovoltaicPower',
|
||||
'InverterRecord.TotalLoadPower',
|
||||
'InverterRecord.TotalGridPower',
|
||||
'InverterRecord.ActivePowerW',
|
||||
'DcDc.Dc.Battery.Power'
|
||||
'InverterRecord.TotalGridPower'
|
||||
];
|
||||
|
||||
// Helper function to safely get a value from a nested path
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ export const getChartOptions = (
|
|||
type: string,
|
||||
dateList: string[],
|
||||
stacked: Boolean,
|
||||
voltageInfo?: chartInfoInterface,
|
||||
powerLabel?: string,
|
||||
temperatureInfo?: chartInfoInterface
|
||||
voltageInfo?: chartInfoInterface
|
||||
): ApexOptions => {
|
||||
return type.includes('daily')
|
||||
? {
|
||||
|
|
@ -59,7 +57,7 @@ export const getChartOptions = (
|
|||
type === 'dailyoverview'
|
||||
? [
|
||||
{
|
||||
seriesName: powerLabel ?? 'Grid Power',
|
||||
seriesName: 'Grid Power',
|
||||
tickAmount: 6,
|
||||
min:
|
||||
chartInfo.min >= 0
|
||||
|
|
@ -96,7 +94,7 @@ export const getChartOptions = (
|
|||
}
|
||||
},
|
||||
{
|
||||
seriesName: powerLabel ?? 'Grid Power',
|
||||
seriesName: 'Grid Power',
|
||||
show: false,
|
||||
tickAmount: 6,
|
||||
min:
|
||||
|
|
@ -125,7 +123,7 @@ export const getChartOptions = (
|
|||
}
|
||||
},
|
||||
{
|
||||
seriesName: powerLabel ?? 'Grid Power',
|
||||
seriesName: 'Grid Power',
|
||||
show: false,
|
||||
tickAmount: 6,
|
||||
min:
|
||||
|
|
@ -194,27 +192,6 @@ 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();
|
||||
}
|
||||
}
|
||||
}] : [])
|
||||
]
|
||||
: {
|
||||
|
|
@ -264,27 +241,18 @@ 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 (
|
||||
(raw === undefined || raw === null || Number.isNaN(raw)
|
||||
? '-'
|
||||
: raw.toFixed(2)) +
|
||||
formatPowerForGraph(val, chartInfo.magnitude).value.toFixed(
|
||||
2
|
||||
) +
|
||||
' ' +
|
||||
(chartInfo.unit ?? '')
|
||||
chartInfo.unit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -566,12 +566,7 @@ function Overview(props: OverviewProps) {
|
|||
>
|
||||
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
|
||||
</Button>
|
||||
{/* Hide "Last week" for SodioHome (product 2) and SodiStoreGrid (product 4) —
|
||||
neither has aggregated weekly data. Uses context `product` because
|
||||
`props.product` isn't passed from Installation.tsx; `props.device` is also
|
||||
never passed, so the legacy `device !== 3` (Growatt) check was a no-op —
|
||||
Growatt installs all have product=2, so the product check covers them. */}
|
||||
{product !== 2 && product !== 4 && (
|
||||
{props.device !== 3 && props.product !== 2 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleWeekData}
|
||||
|
|
@ -668,7 +663,7 @@ function Overview(props: OverviewProps) {
|
|||
</Container>
|
||||
)}
|
||||
|
||||
{!loading && ((dailyData && dailyDataArray.length > 0) || (aggregatedData && aggregatedDataArray.length > 0)) && (
|
||||
{!loading && dailyDataArray.length > 0 && (
|
||||
<Grid item xs={12} md={12}>
|
||||
{dailyData && (
|
||||
<Grid
|
||||
|
|
@ -714,22 +709,11 @@ function Overview(props: OverviewProps) {
|
|||
<ReactApexChart
|
||||
options={{
|
||||
...getChartOptions(
|
||||
// For SodiStoreGrid (product 4), the "overview" yaxis
|
||||
// bucket is unused — drive the left power axis from
|
||||
// Battery Power instead so SOC matches its % axis correctly.
|
||||
product === 4
|
||||
? dailyDataArray[chartState].chartOverview.dcPower
|
||||
: dailyDataArray[chartState].chartOverview.overview,
|
||||
dailyDataArray[chartState].chartOverview.overview,
|
||||
'dailyoverview',
|
||||
[],
|
||||
true,
|
||||
(product === 2 || product === 5 || product === 4)
|
||||
? dailyDataArray[chartState].chartOverview.batteryVoltage
|
||||
: undefined,
|
||||
product === 4 ? 'Battery Power' : undefined,
|
||||
product === 4
|
||||
? dailyDataArray[chartState].chartOverview.temperature
|
||||
: undefined
|
||||
(product === 2 || product === 5) ? dailyDataArray[chartState].chartOverview.batteryVoltage : undefined
|
||||
),
|
||||
chart: {
|
||||
events: {
|
||||
|
|
@ -741,64 +725,32 @@ function Overview(props: OverviewProps) {
|
|||
}
|
||||
}}
|
||||
series={[
|
||||
// SodiStoreGrid (product 4) has no grid meter, no PV, no AC load.
|
||||
...(product !== 4
|
||||
? [
|
||||
{
|
||||
...dailyDataArray[chartState].chartData
|
||||
.gridPower,
|
||||
type: 'line' as const,
|
||||
...dailyDataArray[chartState].chartData.gridPower,
|
||||
type: 'line',
|
||||
color: '#b30000'
|
||||
},
|
||||
{
|
||||
...dailyDataArray[chartState].chartData
|
||||
.pvProduction,
|
||||
type: 'line' as const,
|
||||
type: 'line',
|
||||
color: '#ff9900'
|
||||
},
|
||||
{
|
||||
...dailyDataArray[chartState].chartData
|
||||
.ACLoad,
|
||||
type: 'line' as const,
|
||||
...dailyDataArray[chartState].chartData.ACLoad,
|
||||
type: 'line',
|
||||
color: '#2ecc71'
|
||||
}
|
||||
]
|
||||
: [
|
||||
// For SodiStoreGrid, replace the empty grid/PV/load
|
||||
// series with Battery Power (drives the left kW axis).
|
||||
{
|
||||
...dailyDataArray[chartState].chartData
|
||||
.dcPower,
|
||||
name: 'Battery Power',
|
||||
type: 'line' as const,
|
||||
color: '#e67e22'
|
||||
}
|
||||
]),
|
||||
},
|
||||
{
|
||||
...dailyDataArray[chartState].chartData.soc,
|
||||
type: 'line',
|
||||
color: '#008FFB'
|
||||
},
|
||||
...(product === 2 || product === 5 || product === 4
|
||||
? [
|
||||
{
|
||||
...dailyDataArray[chartState].chartData
|
||||
.batteryVoltage,
|
||||
...((product === 2 || product === 5) ? [{
|
||||
...dailyDataArray[chartState].chartData.batteryVoltage,
|
||||
type: 'line' as const,
|
||||
color: '#9b59b6'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(product === 4
|
||||
? [
|
||||
{
|
||||
...dailyDataArray[chartState].chartData
|
||||
.temperature,
|
||||
type: 'line' as const,
|
||||
color: '#16a085'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
}] : [])
|
||||
]}
|
||||
height={420}
|
||||
/>
|
||||
|
|
@ -1263,8 +1215,6 @@ function Overview(props: OverviewProps) {
|
|||
</Grid>
|
||||
)}
|
||||
|
||||
{/* PV Production + Grid Power — hidden for SodiStoreGrid (no PV, no grid meter) */}
|
||||
{product !== 4 && (
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
|
|
@ -1453,7 +1403,6 @@ function Overview(props: OverviewProps) {
|
|||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{aggregatedData && (product === 2 || product === 5) && (
|
||||
<Grid
|
||||
|
|
@ -1512,7 +1461,7 @@ function Overview(props: OverviewProps) {
|
|||
</Grid>
|
||||
)}
|
||||
|
||||
{dailyData && product !== 4 && (
|
||||
{dailyData && (
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
|
|
|
|||
|
|
@ -161,10 +161,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
|||
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
|
||||
})(),
|
||||
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
||||
relay1: String((props.values.Config as any).Relay1).toLowerCase() === "true",
|
||||
relay2: String((props.values.Config as any).Relay2).toLowerCase() === "true",
|
||||
relay3: String((props.values.Config as any).Relay3).toLowerCase() === "true",
|
||||
relay4: String((props.values.Config as any).Relay4).toLowerCase() === "true",
|
||||
dynamicPricingMode: (props.values.Config as any).DynamicPricingMode ?? 'Disabled',
|
||||
currentPrice: (props.values.Config as any).CurrentPrice?.toString() ?? '',
|
||||
priceToSell: (props.values.Config as any).PriceToSell?.toString() ?? '',
|
||||
|
|
@ -351,14 +347,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
|||
? new Date(formValues.stopTimeChargeandDischargeDayandTime.getTime() - formValues.stopTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
|
||||
: null,
|
||||
controlPermission:formValues.controlPermission,
|
||||
// Relay control — only send for Sinexcel (device=4); leaving them undefined
|
||||
// ensures they're omitted from the UDP payload for Growatt installations.
|
||||
...(device === 4 ? {
|
||||
relay1: Boolean(formValues.relay1),
|
||||
relay2: Boolean(formValues.relay2),
|
||||
relay3: Boolean(formValues.relay3),
|
||||
relay4: Boolean(formValues.relay4),
|
||||
} : {}),
|
||||
dynamicPricingMode: formValues.dynamicPricingMode,
|
||||
currentPrice: formValues.currentPrice,
|
||||
priceToSell: formValues.priceToSell,
|
||||
|
|
@ -534,47 +522,6 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{device === 4 && (
|
||||
<>
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||
<FormattedMessage id="relayControl" defaultMessage="Relay Control" />
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, mb: 1, ml: 1 }}>
|
||||
{[1, 2, 3, 4].map((n) => {
|
||||
const key = `relay${n}` as 'relay1' | 'relay2' | 'relay3' | 'relay4';
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={key}
|
||||
labelPlacement="start"
|
||||
control={
|
||||
<Switch
|
||||
name={key}
|
||||
checked={Boolean(formValues[key])}
|
||||
onChange={(e) => {
|
||||
setFormDirty(true);
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
[key]: e.target.checked,
|
||||
}));
|
||||
}}
|
||||
sx={{ transform: 'scale(1.2)', ml: 1 }}
|
||||
/>
|
||||
}
|
||||
sx={{ ml: 0, mr: 0 }}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id={`relay${n}`}
|
||||
defaultMessage={`Relay ${n}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(device === 3 || device === 4) && (
|
||||
<>
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||
|
|
|
|||
|
|
@ -1,232 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
CircularProgress,
|
||||
Container,
|
||||
Grid,
|
||||
Switch,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import TopologyColumn from './topologyColumn';
|
||||
import {
|
||||
getAmount,
|
||||
getHighestConnectionValue,
|
||||
JSONRecordData
|
||||
} from '../Log/graph.util';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
interface TopologySodistoreGridProps {
|
||||
values: JSONRecordData;
|
||||
connected: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function TopologySodistoreGrid(props: TopologySodistoreGridProps) {
|
||||
if (props.values === null && props.connected == true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const highestConnectionValue =
|
||||
props.values != null ? getHighestConnectionValue(props.values) : 0;
|
||||
|
||||
const [showValues, setShowValues] = useState(false);
|
||||
|
||||
const handleSwitch = () => () => {
|
||||
setShowValues(!showValues);
|
||||
};
|
||||
|
||||
const isMobile = window.innerWidth <= 1490;
|
||||
|
||||
const inv = props.values?.InverterRecord;
|
||||
const dcdc = props.values?.DcDc;
|
||||
const clusters = props.values?.ListOfBatteriesRecord ?? {};
|
||||
const clusterIds = Object.keys(clusters).sort(
|
||||
(a, b) => Number(a) - Number(b)
|
||||
);
|
||||
|
||||
const acDcPower = Number(inv?.ActivePowerW ?? 0);
|
||||
const dcLinkPower = Number(dcdc?.Dc?.Link?.Power ?? 0);
|
||||
const dcLinkVoltage = Number(dcdc?.Dc?.Link?.Voltage ?? 0);
|
||||
const dcdcBatteryVoltage = Number(dcdc?.Dc?.Battery?.Voltage ?? 0);
|
||||
const dcdcBatteryPower = Number(dcdc?.Dc?.Battery?.Power ?? 0);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" style={{ backgroundColor: 'white' }}>
|
||||
<Grid container>
|
||||
{!props.connected && !props.loading && (
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '70vh'
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={60} style={{ color: '#ffc04d' }} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
style={{ color: 'black', fontWeight: 'bold' }}
|
||||
mt={2}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="unableToCommunicate"
|
||||
defaultMessage="Unable to communicate with the installation"
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2" style={{ color: 'black' }}>
|
||||
<FormattedMessage
|
||||
id="pleaseWaitOrRefresh"
|
||||
defaultMessage="Please wait or refresh the page"
|
||||
/>
|
||||
</Typography>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{props.connected && (
|
||||
<>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={12}
|
||||
style={{
|
||||
marginTop: '10px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'right',
|
||||
justifyContent: 'right'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography sx={{ marginTop: '5px', marginRight: '20px' }}>
|
||||
Display Values
|
||||
</Typography>
|
||||
<Switch
|
||||
edge="start"
|
||||
color="secondary"
|
||||
onChange={handleSwitch()}
|
||||
sx={{
|
||||
'& .MuiSwitch-thumb': {
|
||||
backgroundColor: 'orange'
|
||||
},
|
||||
marginLeft: '20px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={12}
|
||||
style={{
|
||||
height: isMobile ? '550px' : '600px',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{/* AC-DC */}
|
||||
<TopologyColumn
|
||||
centerBox={{
|
||||
title: 'AC-DC',
|
||||
data: inv
|
||||
? [{ value: acDcPower, unit: 'W' }]
|
||||
: undefined,
|
||||
connected: true
|
||||
}}
|
||||
centerConnection={{
|
||||
orientation: 'horizontal',
|
||||
data: inv ? { value: acDcPower, unit: 'W' } : undefined,
|
||||
amount: inv
|
||||
? getAmount(highestConnectionValue, acDcPower)
|
||||
: 0,
|
||||
showValues: showValues
|
||||
}}
|
||||
isLast={false}
|
||||
isFirst={true}
|
||||
/>
|
||||
|
||||
{/* DC Link */}
|
||||
<TopologyColumn
|
||||
centerBox={{
|
||||
title: 'DC Link',
|
||||
data: dcdc
|
||||
? [{ value: dcLinkVoltage, unit: 'V' }]
|
||||
: undefined,
|
||||
connected: true
|
||||
}}
|
||||
centerConnection={{
|
||||
orientation: 'horizontal',
|
||||
data: dcdc
|
||||
? { value: dcLinkPower, unit: 'W' }
|
||||
: undefined,
|
||||
amount: dcdc
|
||||
? getAmount(highestConnectionValue, dcLinkPower)
|
||||
: 0,
|
||||
showValues: showValues
|
||||
}}
|
||||
isLast={false}
|
||||
isFirst={false}
|
||||
/>
|
||||
|
||||
{/* DC-DC */}
|
||||
<TopologyColumn
|
||||
centerBox={{
|
||||
title: 'DC-DC',
|
||||
data: dcdc
|
||||
? [{ value: dcdcBatteryVoltage, unit: 'V' }]
|
||||
: undefined,
|
||||
connected: true
|
||||
}}
|
||||
centerConnection={{
|
||||
orientation: 'horizontal',
|
||||
data: dcdc
|
||||
? { value: dcdcBatteryPower, unit: 'W' }
|
||||
: undefined,
|
||||
amount: dcdc
|
||||
? getAmount(highestConnectionValue, dcdcBatteryPower)
|
||||
: 0,
|
||||
showValues: showValues
|
||||
}}
|
||||
isLast={false}
|
||||
isFirst={false}
|
||||
/>
|
||||
|
||||
{/* Battery clusters — one column per cluster, no chained lines */}
|
||||
{clusterIds.map((id) => {
|
||||
const c = clusters[id];
|
||||
return (
|
||||
<TopologyColumn
|
||||
key={`battery-cluster-${id}`}
|
||||
centerBox={{
|
||||
title: `Battery ${id}`,
|
||||
data: c
|
||||
? [
|
||||
{ value: Number(c.Soc ?? 0), unit: '%' },
|
||||
{ value: Number(c.Voltage ?? 0), unit: 'V' },
|
||||
{ value: Number(c.Current ?? 0), unit: 'A' },
|
||||
{
|
||||
value: Number(c.TemperatureCell1 ?? 0),
|
||||
unit: '°C'
|
||||
}
|
||||
]
|
||||
: undefined,
|
||||
connected: true
|
||||
}}
|
||||
isLast={true}
|
||||
isFirst={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopologySodistoreGrid;
|
||||
|
|
@ -67,7 +67,7 @@ function TopologyBox(props: TopologyBoxProps) {
|
|||
|
||||
width: isMobile ? '90px' : '104px',
|
||||
height:
|
||||
props.title === 'Battery' || (props.title && props.title.startsWith('Battery '))
|
||||
props.title === 'Battery'
|
||||
? '165px'
|
||||
: props.title === 'AC Loads' ||
|
||||
props.title === 'DC Loads' ||
|
||||
|
|
|
|||
|
|
@ -112,12 +112,7 @@ export const transformInputToBatteryViewDataJson = async (
|
|||
'.Dc.Current'
|
||||
];
|
||||
|
||||
// SodiStoreGrid (product 4) keeps batteries under ListOfBatteriesRecord[cluster].Devices[id].
|
||||
// Built dynamically below from the first JSON chunk; other products use the fixed list.
|
||||
const pathsToSearch: string[] =
|
||||
product === 4
|
||||
? []
|
||||
: [
|
||||
const pathsToSearch = [
|
||||
'Battery.Devices.1',
|
||||
'Battery.Devices.2',
|
||||
'Battery.Devices.3',
|
||||
|
|
@ -168,6 +163,7 @@ export const transformInputToBatteryViewDataJson = async (
|
|||
//navigate(routes.login);
|
||||
}
|
||||
});
|
||||
|
||||
for (var i = 0; i < timestampArray.length; i++) {
|
||||
timestampPromises.push(
|
||||
fetchJsonDataForOneTime(
|
||||
|
|
@ -315,45 +311,22 @@ export const transformInputToBatteryViewDataJson = async (
|
|||
});
|
||||
});
|
||||
} else {
|
||||
// SaliMax, Salidomo, SodistoreMax, SodistoreGrid: existing logic
|
||||
// SodistoreGrid (product 4) batteries live under ListOfBatteriesRecord[cluster].Devices[id];
|
||||
// enumerate them dynamically and build pathsToSearch in parallel with pathsToSave.
|
||||
let old_length = pathsToSave.length;
|
||||
|
||||
if (product === 4) {
|
||||
const lobr = (result as any)?.ListOfBatteriesRecord ?? {};
|
||||
const clusters = Object.keys(lobr).sort(
|
||||
(a, b) => Number(a) - Number(b)
|
||||
);
|
||||
clusters.forEach((clusterId) => {
|
||||
const devices = Object.keys(lobr[clusterId]?.Devices ?? {}).sort(
|
||||
(a, b) => Number(a) - Number(b)
|
||||
);
|
||||
devices.forEach((deviceId) => {
|
||||
const nodeName = `Node${clusterId}-${deviceId}`;
|
||||
if (!pathsToSave.includes(nodeName)) {
|
||||
pathsToSave.push(nodeName);
|
||||
pathsToSearch.push(
|
||||
`ListOfBatteriesRecord.${clusterId}.Devices.${deviceId}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// SaliMax, Salidomo, SodistoreMax: existing logic
|
||||
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 (battery_nodes.length > old_length) {
|
||||
battery_nodes.forEach((node) => {
|
||||
const node_number =
|
||||
product == 3 ? Number(node) + 1 : Number(node) - 1;
|
||||
product == 3 || product == 4 ? Number(node) + 1 : Number(node) - 1;
|
||||
if (!pathsToSave.includes('Node' + node_number)) {
|
||||
pathsToSave.push('Node' + node_number);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (initialiation) {
|
||||
initialiation = false;
|
||||
|
|
@ -368,7 +341,7 @@ export const transformInputToBatteryViewDataJson = async (
|
|||
});
|
||||
}
|
||||
|
||||
if (pathsToSave.length > old_length) {
|
||||
if (battery_nodes.length > old_length) {
|
||||
categories.forEach((category) => {
|
||||
pathsToSave.forEach((path) => {
|
||||
if (pathsToSave.indexOf(path) >= old_length) {
|
||||
|
|
@ -481,23 +454,6 @@ export const transformInputToDailyDataJson = async (
|
|||
null, // DCLoad not available for SodioHome
|
||||
'SODIOHOME_BATTERY_VOLTAGE'
|
||||
]
|
||||
: product == 4
|
||||
? [
|
||||
// SodistoreGrid: placeholders — actual extraction runs in the
|
||||
// product===4 switch below; nulls only mark "no data path", so
|
||||
// the forEach skips the irrelevant categories. Battery voltage
|
||||
// (index 8) MUST be a non-null placeholder, otherwise the entry
|
||||
// is skipped before the switch can populate it.
|
||||
'ListOfBatteriesRecord', // 0 soc
|
||||
'ListOfBatteriesRecord', // 1 temperature
|
||||
'ListOfBatteriesRecord', // 2 dcPower (battery power)
|
||||
null, // 3 gridPower — no grid meter
|
||||
null, // 4 pvProduction — no PV
|
||||
'DcDc.Dc.Link.Voltage', // 5 dcBusVoltage
|
||||
null, // 6 ACLoad — no AC load meter
|
||||
null, // 7 DCLoad — no DC load meter
|
||||
'ListOfBatteriesRecord' // 8 batteryVoltage (cluster avg)
|
||||
]
|
||||
: [
|
||||
'Battery.Soc',
|
||||
product == 0 ? 'Battery.Temperature' : 'Battery.TemperatureCell1',
|
||||
|
|
@ -643,46 +599,6 @@ export const transformInputToDailyDataJson = async (
|
|||
break;
|
||||
}
|
||||
}
|
||||
} else if (product === 4) {
|
||||
// SodiStoreGrid: only battery + DC-side metrics exist.
|
||||
// No grid meter, no PV, no AC/DC load on this product — those series stay empty.
|
||||
const lobr: Record<string, any> =
|
||||
(result as any)?.ListOfBatteriesRecord ?? {};
|
||||
const clusterValues = Object.values(lobr) as any[];
|
||||
const avgOf = (pick: (c: any) => number | undefined): number | undefined => {
|
||||
const xs = clusterValues
|
||||
.map(pick)
|
||||
.filter((v): v is number => typeof v === 'number');
|
||||
return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : undefined;
|
||||
};
|
||||
const sumOf = (pick: (c: any) => number | undefined): number | undefined => {
|
||||
const xs = clusterValues
|
||||
.map(pick)
|
||||
.filter((v): v is number => typeof v === 'number');
|
||||
return xs.length ? xs.reduce((a, b) => a + b, 0) : undefined;
|
||||
};
|
||||
switch (category_index) {
|
||||
case 0: // soc
|
||||
value = avgOf((c) => c?.Soc);
|
||||
break;
|
||||
case 1: // temperature
|
||||
value = avgOf((c) => c?.TemperatureCell1);
|
||||
break;
|
||||
case 2: // battery power (sum of cluster powers, else DcDc battery-side)
|
||||
value =
|
||||
sumOf((c) => c?.Power) ??
|
||||
(result as any)?.DcDc?.Dc?.Battery?.Power;
|
||||
break;
|
||||
case 5: // dc bus voltage
|
||||
value = (result as any)?.DcDc?.Dc?.Link?.Voltage;
|
||||
break;
|
||||
case 8: // battery voltage (cluster average, else DcDc battery side)
|
||||
value =
|
||||
avgOf((c) => c?.Voltage) ??
|
||||
(result as any)?.DcDc?.Dc?.Battery?.Voltage;
|
||||
break;
|
||||
// case 3 (grid), 4 (PV), 6 (AC load), 7 (DC load) — not available on SodiStoreGrid
|
||||
}
|
||||
} else if (category_index === 4) {
|
||||
// Salimax/Salidomo: Custom logic for 'PvOnDc.Dc.Power'
|
||||
if (get(result, path) !== undefined) {
|
||||
|
|
@ -735,15 +651,6 @@ export const transformInputToDailyDataJson = async (
|
|||
chartOverview[category].magnitude = magnitude;
|
||||
});
|
||||
|
||||
// SodistoreGrid: 18 parallel battery devices easily swing into the kW range.
|
||||
// Pin Battery Power to at least kW so the axis stays stable across day/night cycles.
|
||||
if (product === 4) {
|
||||
chartOverview.dcPower.magnitude = Math.max(
|
||||
chartOverview.dcPower.magnitude,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
chartOverview.soc.unit = '(%)';
|
||||
chartOverview.soc.min = 0;
|
||||
chartOverview.soc.max = 100;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export type ChecklistItem = {
|
|||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 5]);
|
||||
export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 4, 5]);
|
||||
|
||||
export const UPLOADABLE_SUBTASK_KEYS: ReadonlySet<string> = new Set([
|
||||
'checklistStep8Sub1',
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@
|
|||
"couplingType": "AC/DC-Kopplung",
|
||||
"couplingAC": "AC-gekoppelt",
|
||||
"couplingDC": "DC-gekoppelt",
|
||||
"couplingMixed": "Gemischt",
|
||||
"selectModel": "Modell auswählen...",
|
||||
"inverterN": "Wechselrichter {n}",
|
||||
"clusterN": "Cluster {n}",
|
||||
|
|
@ -536,11 +535,6 @@
|
|||
"stopDateTime": "Stoppdatum und -zeit (Startzeit < Stoppzeit)",
|
||||
"installationSetup": "Installationseinrichtung",
|
||||
"batteryLimits": "Batteriegrenzwerte",
|
||||
"relayControl": "Relaissteuerung",
|
||||
"relay1": "Relais 1",
|
||||
"relay2": "Relais 2",
|
||||
"relay3": "Relais 3",
|
||||
"relay4": "Relais 4",
|
||||
"systemSettings": "Systemeinstellungen",
|
||||
"pvPerInverter": "PV pro Wechselrichter",
|
||||
"pvInInverter": "PV in Wechselrichter {number}",
|
||||
|
|
@ -805,10 +799,5 @@
|
|||
"checklistPhasePreparation": "Vorbereitung",
|
||||
"checklistPhaseOnSite": "Vor Ort",
|
||||
"checklistPhaseHandover": "Kundenübergabe",
|
||||
"checklistPhaseComplete": "Abgeschlossen",
|
||||
"activePowerPercent": "Wirkleistung (%)",
|
||||
"minDischargeVoltageV": "Min. Entladespannung (V)",
|
||||
"maxDischargeCurrentA": "Max. Entladestrom (A)",
|
||||
"maxChargeCurrentA": "Max. Ladestrom (A)",
|
||||
"maxChargeVoltageV": "Max. Ladespannung (V)"
|
||||
"checklistPhaseComplete": "Abgeschlossen"
|
||||
}
|
||||
|
|
@ -78,7 +78,6 @@
|
|||
"couplingType": "AC/DC Coupling",
|
||||
"couplingAC": "AC-coupled",
|
||||
"couplingDC": "DC-coupled",
|
||||
"couplingMixed": "Mixed",
|
||||
"selectModel": "Select model...",
|
||||
"inverterN": "Inverter {n}",
|
||||
"clusterN": "Cluster {n}",
|
||||
|
|
@ -284,11 +283,6 @@
|
|||
"stopDateTime": "Stop Date and Time (Start Time < Stop Time)",
|
||||
"installationSetup": "Installation Setup",
|
||||
"batteryLimits": "Battery Limits",
|
||||
"relayControl": "Relay Control",
|
||||
"relay1": "Relay 1",
|
||||
"relay2": "Relay 2",
|
||||
"relay3": "Relay 3",
|
||||
"relay4": "Relay 4",
|
||||
"systemSettings": "System Settings",
|
||||
"pvPerInverter": "PV per Inverter",
|
||||
"pvInInverter": "PV in Inverter {number}",
|
||||
|
|
@ -553,10 +547,5 @@
|
|||
"checklistPhasePreparation": "Preparation",
|
||||
"checklistPhaseOnSite": "On-site",
|
||||
"checklistPhaseHandover": "Customer handover",
|
||||
"checklistPhaseComplete": "Complete",
|
||||
"activePowerPercent": "Active Power (%)",
|
||||
"minDischargeVoltageV": "Min Discharge Voltage (V)",
|
||||
"maxDischargeCurrentA": "Max Discharge Current (A)",
|
||||
"maxChargeCurrentA": "Max Charge Current (A)",
|
||||
"maxChargeVoltageV": "Max Charge Voltage (V)"
|
||||
"checklistPhaseComplete": "Complete"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,6 @@
|
|||
"couplingType": "Couplage AC/DC",
|
||||
"couplingAC": "Couplage AC",
|
||||
"couplingDC": "Couplage DC",
|
||||
"couplingMixed": "Mixte",
|
||||
"selectModel": "Sélectionner le modèle...",
|
||||
"inverterN": "Onduleur {n}",
|
||||
"clusterN": "Cluster {n}",
|
||||
|
|
@ -536,11 +535,6 @@
|
|||
"stopDateTime": "Date et heure de fin (Début < Fin)",
|
||||
"installationSetup": "Configuration de l'installation",
|
||||
"batteryLimits": "Limites de la batterie",
|
||||
"relayControl": "Commande des relais",
|
||||
"relay1": "Relais 1",
|
||||
"relay2": "Relais 2",
|
||||
"relay3": "Relais 3",
|
||||
"relay4": "Relais 4",
|
||||
"systemSettings": "Paramètres système",
|
||||
"pvPerInverter": "PV par onduleur",
|
||||
"pvInInverter": "PV dans l'onduleur {number}",
|
||||
|
|
@ -805,10 +799,5 @@
|
|||
"checklistPhasePreparation": "Préparation",
|
||||
"checklistPhaseOnSite": "Sur site",
|
||||
"checklistPhaseHandover": "Transfert client",
|
||||
"checklistPhaseComplete": "Terminé",
|
||||
"activePowerPercent": "Puissance active (%)",
|
||||
"minDischargeVoltageV": "Tension de décharge min. (V)",
|
||||
"maxDischargeCurrentA": "Courant de décharge max. (A)",
|
||||
"maxChargeCurrentA": "Courant de charge max. (A)",
|
||||
"maxChargeVoltageV": "Tension de charge max. (V)"
|
||||
"checklistPhaseComplete": "Terminé"
|
||||
}
|
||||
|
|
@ -78,7 +78,6 @@
|
|||
"couplingType": "Accoppiamento AC/DC",
|
||||
"couplingAC": "Accoppiamento AC",
|
||||
"couplingDC": "Accoppiamento DC",
|
||||
"couplingMixed": "Misto",
|
||||
"selectModel": "Seleziona modello...",
|
||||
"inverterN": "Inverter {n}",
|
||||
"clusterN": "Cluster {n}",
|
||||
|
|
@ -536,11 +535,6 @@
|
|||
"stopDateTime": "Data e ora di fine (Inizio < Fine)",
|
||||
"installationSetup": "Configurazione dell'installazione",
|
||||
"batteryLimits": "Limiti della batteria",
|
||||
"relayControl": "Controllo relè",
|
||||
"relay1": "Relè 1",
|
||||
"relay2": "Relè 2",
|
||||
"relay3": "Relè 3",
|
||||
"relay4": "Relè 4",
|
||||
"systemSettings": "Impostazioni di sistema",
|
||||
"pvPerInverter": "PV per inverter",
|
||||
"pvInInverter": "PV nell'inverter {number}",
|
||||
|
|
@ -805,10 +799,5 @@
|
|||
"checklistPhasePreparation": "Preparazione",
|
||||
"checklistPhaseOnSite": "In sito",
|
||||
"checklistPhaseHandover": "Consegna cliente",
|
||||
"checklistPhaseComplete": "Completato",
|
||||
"activePowerPercent": "Potenza attiva (%)",
|
||||
"minDischargeVoltageV": "Tensione di scarica min. (V)",
|
||||
"maxDischargeCurrentA": "Corrente di scarica max. (A)",
|
||||
"maxChargeCurrentA": "Corrente di carica max. (A)",
|
||||
"maxChargeVoltageV": "Tensione di carica max. (V)"
|
||||
"checklistPhaseComplete": "Completato"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue