Compare commits
9 Commits
4fa5ba60c8
...
646b6c0e20
| Author | SHA1 | Date |
|---|---|---|
|
|
646b6c0e20 | |
|
|
b11e3340fd | |
|
|
77abe03f9c | |
|
|
8b8fe8cf2e | |
|
|
2e137cf550 | |
|
|
2e43abc947 | |
|
|
3c8b05bbf9 | |
|
|
c1b456639a | |
|
|
74eaa258e1 |
|
|
@ -28,6 +28,13 @@ public class Configuration
|
||||||
public DateTime? StartTimeChargeandDischargeDayandTime { get; set; }
|
public DateTime? StartTimeChargeandDischargeDayandTime { get; set; }
|
||||||
public DateTime? StopTimeChargeandDischargeDayandTime { 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.
|
// Sinexcel Dynamic Pricing (under GridPriority) — strings for demo; engine will parse later.
|
||||||
public string? DynamicPricingMode { get; set; }
|
public string? DynamicPricingMode { get; set; }
|
||||||
public string? NetworkProvider { get; set; }
|
public string? NetworkProvider { get; set; }
|
||||||
|
|
@ -40,6 +47,13 @@ public class Configuration
|
||||||
public string? TimeToBuyFrom { get; set; }
|
public string? TimeToBuyFrom { get; set; }
|
||||||
public string? TimeToBuyTo { 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
|
public enum CalibrationChargeType
|
||||||
|
|
|
||||||
|
|
@ -45,14 +45,36 @@ function BatteryView(props: BatteryViewProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { product, setProduct } = useContext(ProductIdContext);
|
const { product, setProduct } = useContext(ProductIdContext);
|
||||||
|
|
||||||
const sortedBatteryView =
|
// SodiStoreGrid (product 4) keeps batteries under ListOfBatteriesRecord[cluster].Devices[id].
|
||||||
props.values != null && props.values?.Battery?.Devices
|
// Flatten into a single list with composite IDs "{cluster}-{device}" so the existing
|
||||||
? Object.entries(props.values.Battery.Devices)
|
// BatteryView table renders without further changes.
|
||||||
.map(([BatteryId, battery]) => {
|
const sortedBatteryView = (() => {
|
||||||
return { BatteryId, battery }; // Here we return an object with the id and device
|
if (props.values == null) return [];
|
||||||
})
|
|
||||||
.sort((a, b) => parseInt(b.BatteryId) - parseInt(a.BatteryId))
|
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);
|
const [loading, setLoading] = useState(sortedBatteryView.length == 0);
|
||||||
|
|
||||||
|
|
@ -177,39 +199,37 @@ function BatteryView(props: BatteryViewProps) {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{product === 0
|
{product === 0
|
||||||
? Object.entries(props.values.Battery.Devices).map(
|
? sortedBatteryView.map(({ BatteryId, battery }) => (
|
||||||
([BatteryId, battery]) => (
|
<Route
|
||||||
<Route
|
key={routes.detailed_view + BatteryId}
|
||||||
key={routes.detailed_view + BatteryId}
|
path={routes.detailed_view + BatteryId}
|
||||||
path={routes.detailed_view + BatteryId}
|
element={
|
||||||
element={
|
<DetailedBatteryView
|
||||||
<DetailedBatteryView
|
batteryId={Number(BatteryId)}
|
||||||
batteryId={Number(BatteryId)}
|
s3Credentials={props.s3Credentials}
|
||||||
s3Credentials={props.s3Credentials}
|
batteryData={battery}
|
||||||
batteryData={battery}
|
installationId={props.installationId}
|
||||||
installationId={props.installationId}
|
productNum={product}
|
||||||
productNum={product}
|
></DetailedBatteryView>
|
||||||
></DetailedBatteryView>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
))
|
||||||
)
|
: sortedBatteryView.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}
|
||||||
|
s3Credentials={props.s3Credentials}
|
||||||
|
batteryData={battery}
|
||||||
|
installationId={props.installationId}
|
||||||
|
productNum={product}
|
||||||
|
></DetailedBatteryViewSodistore>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
: Object.entries(props.values.Battery.Devices).map(
|
|
||||||
([BatteryId, battery]) => (
|
|
||||||
<Route
|
|
||||||
key={routes.detailed_view + BatteryId}
|
|
||||||
path={routes.detailed_view + BatteryId}
|
|
||||||
element={
|
|
||||||
<DetailedBatteryViewSodistore
|
|
||||||
batteryId={Number(BatteryId)}
|
|
||||||
s3Credentials={props.s3Credentials}
|
|
||||||
batteryData={battery}
|
|
||||||
installationId={props.installationId}
|
|
||||||
productNum={product}
|
|
||||||
></DetailedBatteryViewSodistore>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</Routes>
|
</Routes>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
@ -262,7 +282,7 @@ function BatteryView(props: BatteryViewProps) {
|
||||||
component="th"
|
component="th"
|
||||||
scope="row"
|
scope="row"
|
||||||
align="center"
|
align="center"
|
||||||
sx={{ fontWeight: 'bold' }}
|
sx={{ fontWeight: 'bold', whiteSpace: 'nowrap', minWidth: '100px' }}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
style={{ color: 'black' }}
|
style={{ color: 'black' }}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import GaugeChart from 'react-gauge-chart';
|
import GaugeChart from 'react-gauge-chart';
|
||||||
|
|
||||||
interface DetailedBatteryViewSodistoreProps {
|
interface DetailedBatteryViewSodistoreProps {
|
||||||
batteryId: number;
|
// 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;
|
||||||
s3Credentials: I_S3Credentials;
|
s3Credentials: I_S3Credentials;
|
||||||
batteryData: Device;
|
batteryData: Device;
|
||||||
installationId: number;
|
installationId: number;
|
||||||
|
|
|
||||||
|
|
@ -134,34 +134,22 @@ function MainStats(props: MainStatsProps) {
|
||||||
|
|
||||||
function generateSeries(chartData, category, color) {
|
function generateSeries(chartData, category, color) {
|
||||||
const series = [];
|
const series = [];
|
||||||
const pathsToSearch = [
|
// Use all actually-present series keys so product 4 (SodiStoreGrid)
|
||||||
'Node0',
|
// composite names like "Node1-1".."Node3-6" are picked up too.
|
||||||
'Node1',
|
const presentPaths = Object.keys(chartData[category]?.data ?? {}).sort();
|
||||||
'Node2',
|
|
||||||
'Node3',
|
|
||||||
'Node4',
|
|
||||||
'Node5',
|
|
||||||
'Node6',
|
|
||||||
'Node7',
|
|
||||||
'Node8',
|
|
||||||
'Node9',
|
|
||||||
'Node10'
|
|
||||||
];
|
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
pathsToSearch.forEach((devicePath) => {
|
presentPaths.forEach((devicePath) => {
|
||||||
if (
|
if (chartData[category].data[devicePath]?.data?.length) {
|
||||||
Object.hasOwnProperty.call(chartData[category].data, devicePath) &&
|
const palette =
|
||||||
chartData[category].data[devicePath].data.length != 0
|
color === 'blue'
|
||||||
) {
|
? blueColors
|
||||||
|
: color === 'red'
|
||||||
|
? redColors
|
||||||
|
: orangeColors;
|
||||||
series.push({
|
series.push({
|
||||||
...chartData[category].data[devicePath],
|
...chartData[category].data[devicePath],
|
||||||
color:
|
color: palette[i % palette.length]
|
||||||
color === 'blue'
|
|
||||||
? blueColors[i]
|
|
||||||
: color === 'red'
|
|
||||||
? redColors[i]
|
|
||||||
: orangeColors[i]
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
i++;
|
i++;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
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,6 +924,9 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
<MenuItem value="DC">
|
<MenuItem value="DC">
|
||||||
<FormattedMessage id="couplingDC" defaultMessage="DC-coupled" />
|
<FormattedMessage id="couplingDC" defaultMessage="DC-coupled" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem value="Mixed">
|
||||||
|
<FormattedMessage id="couplingMixed" defaultMessage="Mixed" />
|
||||||
|
</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,10 @@ import Information from '../Information/Information';
|
||||||
import { UserType } from '../../../interfaces/UserTypes';
|
import { UserType } from '../../../interfaces/UserTypes';
|
||||||
import HistoryOfActions from '../History/History';
|
import HistoryOfActions from '../History/History';
|
||||||
import Topology from '../Topology/Topology';
|
import Topology from '../Topology/Topology';
|
||||||
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
import TopologySodistoreGrid from '../Topology/TopologySodistoreGrid';
|
||||||
import BatteryView from '../BatteryView/BatteryView';
|
import BatteryView from '../BatteryView/BatteryView';
|
||||||
import Configuration from '../Configuration/Configuration';
|
import Configuration from '../Configuration/Configuration';
|
||||||
|
import ConfigurationSodistoreGrid from '../Configuration/ConfigurationSodistoreGrid';
|
||||||
import PvView from '../PvView/PvView';
|
import PvView from '../PvView/PvView';
|
||||||
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||||
import DocumentsTab from '../Documents/DocumentsTab';
|
import DocumentsTab from '../Documents/DocumentsTab';
|
||||||
|
|
@ -51,6 +52,10 @@ function Installation(props: singleInstallationProps) {
|
||||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||||
const [values, setValues] = useState<JSONRecordData | null>(null);
|
const [values, setValues] = useState<JSONRecordData | null>(null);
|
||||||
const status = props.current_installation.status;
|
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 [connected, setConnected] = useState(true);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
|
@ -80,11 +85,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)
|
//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);
|
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 () => {
|
const fetchDataForOneTime = async () => {
|
||||||
var timeperiodToSearch = 70;
|
var timeperiodToSearch = 70;
|
||||||
let res;
|
let res;
|
||||||
let timestampToFetch;
|
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) {
|
for (var i = timeperiodToSearch; i > 0; i -= 2) {
|
||||||
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
|
timestampToFetch = UnixTime.now().earlier(TimeSpan.fromSeconds(i));
|
||||||
try {
|
try {
|
||||||
|
|
@ -122,6 +177,55 @@ function Installation(props: singleInstallationProps) {
|
||||||
let res;
|
let res;
|
||||||
let timestampToFetch;
|
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) {
|
for (var i = 0; i < timeperiodToSearch; i += 2) {
|
||||||
if (!continueFetching.current) {
|
if (!continueFetching.current) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -194,12 +298,14 @@ function Installation(props: singleInstallationProps) {
|
||||||
setCurrentTab(path[path.length - 1]);
|
setCurrentTab(path[path.length - 1]);
|
||||||
}, [location]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (status === -1) {
|
if (status === -1 && props.current_installation.product !== 4) {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
}
|
}
|
||||||
}, [status]);
|
}, [status, props.current_installation.product]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -276,7 +382,7 @@ function Installation(props: singleInstallationProps) {
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentTab == 'live' && values && (
|
{currentTab == 'live' && values && values.EssControl?.Mode && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<Typography
|
<Typography
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
|
|
@ -326,7 +432,7 @@ function Installation(props: singleInstallationProps) {
|
||||||
marginTop: '-10px'
|
marginTop: '-10px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{status === -1 ? (
|
{status === -1 && !(props.current_installation.product === 4 && connected) ? (
|
||||||
<CancelIcon
|
<CancelIcon
|
||||||
style={{
|
style={{
|
||||||
width: '23px',
|
width: '23px',
|
||||||
|
|
@ -361,7 +467,8 @@ function Installation(props: singleInstallationProps) {
|
||||||
? 'red'
|
? 'red'
|
||||||
: status === 1
|
: status === 1
|
||||||
? 'orange'
|
? 'orange'
|
||||||
: status === -1 || status === -2
|
: (status === -1 || status === -2) &&
|
||||||
|
!(props.current_installation.product === 4 && connected)
|
||||||
? 'transparent'
|
? 'transparent'
|
||||||
: 'green'
|
: 'green'
|
||||||
}}
|
}}
|
||||||
|
|
@ -469,12 +576,11 @@ function Installation(props: singleInstallationProps) {
|
||||||
path={routes.live}
|
path={routes.live}
|
||||||
element={
|
element={
|
||||||
props.current_installation.product === 4 ? (
|
props.current_installation.product === 4 ? (
|
||||||
<TopologySodistoreHome
|
<TopologySodistoreGrid
|
||||||
values={values}
|
values={values}
|
||||||
connected={connected}
|
connected={connected}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
batteryClusterNumber={props.current_installation.batteryClusterNumber}
|
></TopologySodistoreGrid>
|
||||||
></TopologySodistoreHome>
|
|
||||||
) : (
|
) : (
|
||||||
<Topology
|
<Topology
|
||||||
values={values}
|
values={values}
|
||||||
|
|
@ -501,20 +607,10 @@ function Installation(props: singleInstallationProps) {
|
||||||
path={routes.configuration}
|
path={routes.configuration}
|
||||||
element={
|
element={
|
||||||
props.current_installation.product === 4 ? (
|
props.current_installation.product === 4 ? (
|
||||||
// TODO: SodistoreGrid — implement actual configuration
|
<ConfigurationSodistoreGrid
|
||||||
<Container
|
values={values}
|
||||||
maxWidth="xl"
|
id={props.current_installation.id}
|
||||||
sx={{
|
/>
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
height: '40vh'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body1" color="text.secondary">
|
|
||||||
Configuration not yet available
|
|
||||||
</Typography>
|
|
||||||
</Container>
|
|
||||||
) : (
|
) : (
|
||||||
<Configuration
|
<Configuration
|
||||||
values={values}
|
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
|
// TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready
|
||||||
const hidePvView = props.product === 4;
|
const hidePvView = props.product === 4;
|
||||||
|
|
||||||
// Checklist is only shown for Sodistore Grid (product=4) in the Installations view.
|
// Checklist is not shown for any product in the Installations view.
|
||||||
// Salimax (0) / Salidomo (1) / SodistoreMax (3) use different onboarding flows.
|
// Salimax (0) / Salidomo (1) / SodistoreMax (3) / SodistoreGrid (4) use different onboarding flows.
|
||||||
const showChecklist = props.product === 4;
|
const showChecklist = false;
|
||||||
|
|
||||||
const singleInstallationTabs = (
|
const singleInstallationTabs = (
|
||||||
currentUser.userType == UserType.admin
|
currentUser.userType == UserType.admin
|
||||||
|
|
|
||||||
|
|
@ -594,6 +594,19 @@ export interface JSONRecordData {
|
||||||
// [PvId: string]: PvString;
|
// [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 = (
|
export const parseChunkJson = (
|
||||||
|
|
@ -734,6 +747,12 @@ export type ConfigurationValues = {
|
||||||
timeToSellTo?: string;
|
timeToSellTo?: string;
|
||||||
timeToBuyFrom?: string;
|
timeToBuyFrom?: string;
|
||||||
timeToBuyTo?: string;
|
timeToBuyTo?: string;
|
||||||
|
|
||||||
|
// For SodistoreHome + SodistorePro Sinexcel (device=4): relay control toggles
|
||||||
|
relay1?: boolean;
|
||||||
|
relay2?: boolean;
|
||||||
|
relay3?: boolean;
|
||||||
|
relay4?: boolean;
|
||||||
};
|
};
|
||||||
//
|
//
|
||||||
// export interface Pv {
|
// export interface Pv {
|
||||||
|
|
@ -1174,7 +1193,9 @@ export const getHighestConnectionValue = (values: JSONRecordData) => {
|
||||||
'InverterRecord.TotalBatteryPower',
|
'InverterRecord.TotalBatteryPower',
|
||||||
'InverterRecord.TotalPhotovoltaicPower',
|
'InverterRecord.TotalPhotovoltaicPower',
|
||||||
'InverterRecord.TotalLoadPower',
|
'InverterRecord.TotalLoadPower',
|
||||||
'InverterRecord.TotalGridPower'
|
'InverterRecord.TotalGridPower',
|
||||||
|
'InverterRecord.ActivePowerW',
|
||||||
|
'DcDc.Dc.Battery.Power'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper function to safely get a value from a nested path
|
// Helper function to safely get a value from a nested path
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ export const getChartOptions = (
|
||||||
type: string,
|
type: string,
|
||||||
dateList: string[],
|
dateList: string[],
|
||||||
stacked: Boolean,
|
stacked: Boolean,
|
||||||
voltageInfo?: chartInfoInterface
|
voltageInfo?: chartInfoInterface,
|
||||||
|
powerLabel?: string,
|
||||||
|
temperatureInfo?: chartInfoInterface
|
||||||
): ApexOptions => {
|
): ApexOptions => {
|
||||||
return type.includes('daily')
|
return type.includes('daily')
|
||||||
? {
|
? {
|
||||||
|
|
@ -57,7 +59,7 @@ export const getChartOptions = (
|
||||||
type === 'dailyoverview'
|
type === 'dailyoverview'
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
seriesName: 'Grid Power',
|
seriesName: powerLabel ?? 'Grid Power',
|
||||||
tickAmount: 6,
|
tickAmount: 6,
|
||||||
min:
|
min:
|
||||||
chartInfo.min >= 0
|
chartInfo.min >= 0
|
||||||
|
|
@ -94,7 +96,7 @@ export const getChartOptions = (
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
seriesName: 'Grid Power',
|
seriesName: powerLabel ?? 'Grid Power',
|
||||||
show: false,
|
show: false,
|
||||||
tickAmount: 6,
|
tickAmount: 6,
|
||||||
min:
|
min:
|
||||||
|
|
@ -123,7 +125,7 @@ export const getChartOptions = (
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
seriesName: 'Grid Power',
|
seriesName: powerLabel ?? 'Grid Power',
|
||||||
show: false,
|
show: false,
|
||||||
tickAmount: 6,
|
tickAmount: 6,
|
||||||
min:
|
min:
|
||||||
|
|
@ -192,6 +194,27 @@ export const getChartOptions = (
|
||||||
return Math.round(value).toString();
|
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: {
|
y: {
|
||||||
formatter: function (val, { seriesIndex, w }) {
|
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;
|
const seriesName = w.config.series[seriesIndex].name;
|
||||||
if (seriesName === 'Battery SOC') {
|
if (seriesName === 'Battery SOC') {
|
||||||
return val.toFixed(2) + ' %';
|
return val.toFixed(2) + ' %';
|
||||||
} else if (seriesName === 'Battery Voltage') {
|
} else if (seriesName === 'Battery Voltage') {
|
||||||
return val.toFixed(2) + ' (V)';
|
return val.toFixed(2) + ' (V)';
|
||||||
|
} else if (seriesName === 'Battery Temperature') {
|
||||||
|
return val.toFixed(2) + ' (°C)';
|
||||||
} else {
|
} else {
|
||||||
|
const formatted = formatPowerForGraph(val, chartInfo.magnitude);
|
||||||
|
const raw = formatted?.value;
|
||||||
return (
|
return (
|
||||||
formatPowerForGraph(val, chartInfo.magnitude).value.toFixed(
|
(raw === undefined || raw === null || Number.isNaN(raw)
|
||||||
2
|
? '-'
|
||||||
) +
|
: raw.toFixed(2)) +
|
||||||
' ' +
|
' ' +
|
||||||
chartInfo.unit
|
(chartInfo.unit ?? '')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -566,7 +566,12 @@ function Overview(props: OverviewProps) {
|
||||||
>
|
>
|
||||||
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
|
<FormattedMessage id="24_hours" defaultMessage="24-hours" />
|
||||||
</Button>
|
</Button>
|
||||||
{props.device !== 3 && props.product !== 2 && (
|
{/* 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 && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleWeekData}
|
onClick={handleWeekData}
|
||||||
|
|
@ -663,7 +668,7 @@ function Overview(props: OverviewProps) {
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && dailyDataArray.length > 0 && (
|
{!loading && ((dailyData && dailyDataArray.length > 0) || (aggregatedData && aggregatedDataArray.length > 0)) && (
|
||||||
<Grid item xs={12} md={12}>
|
<Grid item xs={12} md={12}>
|
||||||
{dailyData && (
|
{dailyData && (
|
||||||
<Grid
|
<Grid
|
||||||
|
|
@ -709,11 +714,22 @@ function Overview(props: OverviewProps) {
|
||||||
<ReactApexChart
|
<ReactApexChart
|
||||||
options={{
|
options={{
|
||||||
...getChartOptions(
|
...getChartOptions(
|
||||||
dailyDataArray[chartState].chartOverview.overview,
|
// 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,
|
||||||
'dailyoverview',
|
'dailyoverview',
|
||||||
[],
|
[],
|
||||||
true,
|
true,
|
||||||
(product === 2 || product === 5) ? dailyDataArray[chartState].chartOverview.batteryVoltage : undefined
|
(product === 2 || product === 5 || product === 4)
|
||||||
|
? dailyDataArray[chartState].chartOverview.batteryVoltage
|
||||||
|
: undefined,
|
||||||
|
product === 4 ? 'Battery Power' : undefined,
|
||||||
|
product === 4
|
||||||
|
? dailyDataArray[chartState].chartOverview.temperature
|
||||||
|
: undefined
|
||||||
),
|
),
|
||||||
chart: {
|
chart: {
|
||||||
events: {
|
events: {
|
||||||
|
|
@ -725,32 +741,64 @@ function Overview(props: OverviewProps) {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
series={[
|
series={[
|
||||||
{
|
// SodiStoreGrid (product 4) has no grid meter, no PV, no AC load.
|
||||||
...dailyDataArray[chartState].chartData.gridPower,
|
...(product !== 4
|
||||||
type: 'line',
|
? [
|
||||||
color: '#b30000'
|
{
|
||||||
},
|
...dailyDataArray[chartState].chartData
|
||||||
{
|
.gridPower,
|
||||||
...dailyDataArray[chartState].chartData
|
type: 'line' as const,
|
||||||
.pvProduction,
|
color: '#b30000'
|
||||||
type: 'line',
|
},
|
||||||
color: '#ff9900'
|
{
|
||||||
},
|
...dailyDataArray[chartState].chartData
|
||||||
{
|
.pvProduction,
|
||||||
...dailyDataArray[chartState].chartData.ACLoad,
|
type: 'line' as const,
|
||||||
type: 'line',
|
color: '#ff9900'
|
||||||
color: '#2ecc71'
|
},
|
||||||
},
|
{
|
||||||
|
...dailyDataArray[chartState].chartData
|
||||||
|
.ACLoad,
|
||||||
|
type: 'line' as const,
|
||||||
|
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,
|
...dailyDataArray[chartState].chartData.soc,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
color: '#008FFB'
|
color: '#008FFB'
|
||||||
},
|
},
|
||||||
...((product === 2 || product === 5) ? [{
|
...(product === 2 || product === 5 || product === 4
|
||||||
...dailyDataArray[chartState].chartData.batteryVoltage,
|
? [
|
||||||
type: 'line' as const,
|
{
|
||||||
color: '#9b59b6'
|
...dailyDataArray[chartState].chartData
|
||||||
}] : [])
|
.batteryVoltage,
|
||||||
|
type: 'line' as const,
|
||||||
|
color: '#9b59b6'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(product === 4
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
...dailyDataArray[chartState].chartData
|
||||||
|
.temperature,
|
||||||
|
type: 'line' as const,
|
||||||
|
color: '#16a085'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
]}
|
]}
|
||||||
height={420}
|
height={420}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1215,6 +1263,8 @@ function Overview(props: OverviewProps) {
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PV Production + Grid Power — hidden for SodiStoreGrid (no PV, no grid meter) */}
|
||||||
|
{product !== 4 && (
|
||||||
<Grid
|
<Grid
|
||||||
container
|
container
|
||||||
direction="row"
|
direction="row"
|
||||||
|
|
@ -1403,6 +1453,7 @@ function Overview(props: OverviewProps) {
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
{aggregatedData && (product === 2 || product === 5) && (
|
{aggregatedData && (product === 2 || product === 5) && (
|
||||||
<Grid
|
<Grid
|
||||||
|
|
@ -1461,7 +1512,7 @@ function Overview(props: OverviewProps) {
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dailyData && (
|
{dailyData && product !== 4 && (
|
||||||
<Grid
|
<Grid
|
||||||
container
|
container
|
||||||
direction="row"
|
direction="row"
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,10 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
|
return parsed && parsed.year() >= 2020 ? parsed.toDate() : new Date();
|
||||||
})(),
|
})(),
|
||||||
controlPermission: String(props.values.Config.ControlPermission).toLowerCase() === "true",
|
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',
|
dynamicPricingMode: (props.values.Config as any).DynamicPricingMode ?? 'Disabled',
|
||||||
currentPrice: (props.values.Config as any).CurrentPrice?.toString() ?? '',
|
currentPrice: (props.values.Config as any).CurrentPrice?.toString() ?? '',
|
||||||
priceToSell: (props.values.Config as any).PriceToSell?.toString() ?? '',
|
priceToSell: (props.values.Config as any).PriceToSell?.toString() ?? '',
|
||||||
|
|
@ -347,6 +351,14 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
? new Date(formValues.stopTimeChargeandDischargeDayandTime.getTime() - formValues.stopTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
|
? new Date(formValues.stopTimeChargeandDischargeDayandTime.getTime() - formValues.stopTimeChargeandDischargeDayandTime.getTimezoneOffset() * 60000)
|
||||||
: null,
|
: null,
|
||||||
controlPermission:formValues.controlPermission,
|
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,
|
dynamicPricingMode: formValues.dynamicPricingMode,
|
||||||
currentPrice: formValues.currentPrice,
|
currentPrice: formValues.currentPrice,
|
||||||
priceToSell: formValues.priceToSell,
|
priceToSell: formValues.priceToSell,
|
||||||
|
|
@ -522,6 +534,47 @@ function SodistoreHomeConfigurationV2(props: SodistoreHomeConfigurationProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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) && (
|
{(device === 3 || device === 4) && (
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
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',
|
width: isMobile ? '90px' : '104px',
|
||||||
height:
|
height:
|
||||||
props.title === 'Battery'
|
props.title === 'Battery' || (props.title && props.title.startsWith('Battery '))
|
||||||
? '165px'
|
? '165px'
|
||||||
: props.title === 'AC Loads' ||
|
: props.title === 'AC Loads' ||
|
||||||
props.title === 'DC Loads' ||
|
props.title === 'DC Loads' ||
|
||||||
|
|
|
||||||
|
|
@ -112,18 +112,23 @@ export const transformInputToBatteryViewDataJson = async (
|
||||||
'.Dc.Current'
|
'.Dc.Current'
|
||||||
];
|
];
|
||||||
|
|
||||||
const pathsToSearch = [
|
// SodiStoreGrid (product 4) keeps batteries under ListOfBatteriesRecord[cluster].Devices[id].
|
||||||
'Battery.Devices.1',
|
// Built dynamically below from the first JSON chunk; other products use the fixed list.
|
||||||
'Battery.Devices.2',
|
const pathsToSearch: string[] =
|
||||||
'Battery.Devices.3',
|
product === 4
|
||||||
'Battery.Devices.4',
|
? []
|
||||||
'Battery.Devices.5',
|
: [
|
||||||
'Battery.Devices.6',
|
'Battery.Devices.1',
|
||||||
'Battery.Devices.7',
|
'Battery.Devices.2',
|
||||||
'Battery.Devices.8',
|
'Battery.Devices.3',
|
||||||
'Battery.Devices.9',
|
'Battery.Devices.4',
|
||||||
'Battery.Devices.10'
|
'Battery.Devices.5',
|
||||||
];
|
'Battery.Devices.6',
|
||||||
|
'Battery.Devices.7',
|
||||||
|
'Battery.Devices.8',
|
||||||
|
'Battery.Devices.9',
|
||||||
|
'Battery.Devices.10'
|
||||||
|
];
|
||||||
|
|
||||||
const pathsToSave = [];
|
const pathsToSave = [];
|
||||||
|
|
||||||
|
|
@ -163,7 +168,6 @@ export const transformInputToBatteryViewDataJson = async (
|
||||||
//navigate(routes.login);
|
//navigate(routes.login);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
for (var i = 0; i < timestampArray.length; i++) {
|
for (var i = 0; i < timestampArray.length; i++) {
|
||||||
timestampPromises.push(
|
timestampPromises.push(
|
||||||
fetchJsonDataForOneTime(
|
fetchJsonDataForOneTime(
|
||||||
|
|
@ -311,21 +315,44 @@ export const transformInputToBatteryViewDataJson = async (
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// SaliMax, Salidomo, SodistoreMax: existing logic
|
// SaliMax, Salidomo, SodistoreMax, SodistoreGrid: existing logic
|
||||||
const battery_nodes =
|
// SodistoreGrid (product 4) batteries live under ListOfBatteriesRecord[cluster].Devices[id];
|
||||||
result.Config.Devices.BatteryNodes.toString().split(',');
|
// enumerate them dynamically and build pathsToSearch in parallel with pathsToSave.
|
||||||
|
|
||||||
//Initialize the chartData structure based on the node names extracted from the first result
|
|
||||||
let old_length = pathsToSave.length;
|
let old_length = pathsToSave.length;
|
||||||
|
|
||||||
if (battery_nodes.length > old_length) {
|
if (product === 4) {
|
||||||
battery_nodes.forEach((node) => {
|
const lobr = (result as any)?.ListOfBatteriesRecord ?? {};
|
||||||
const node_number =
|
const clusters = Object.keys(lobr).sort(
|
||||||
product == 3 || product == 4 ? Number(node) + 1 : Number(node) - 1;
|
(a, b) => Number(a) - Number(b)
|
||||||
if (!pathsToSave.includes('Node' + node_number)) {
|
);
|
||||||
pathsToSave.push('Node' + node_number);
|
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 {
|
||||||
|
const battery_nodes =
|
||||||
|
result.Config.Devices.BatteryNodes.toString().split(',');
|
||||||
|
|
||||||
|
//Initialize the chartData structure based on the node names extracted from the first result
|
||||||
|
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) {
|
if (initialiation) {
|
||||||
|
|
@ -341,7 +368,7 @@ export const transformInputToBatteryViewDataJson = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (battery_nodes.length > old_length) {
|
if (pathsToSave.length > old_length) {
|
||||||
categories.forEach((category) => {
|
categories.forEach((category) => {
|
||||||
pathsToSave.forEach((path) => {
|
pathsToSave.forEach((path) => {
|
||||||
if (pathsToSave.indexOf(path) >= old_length) {
|
if (pathsToSave.indexOf(path) >= old_length) {
|
||||||
|
|
@ -454,6 +481,23 @@ export const transformInputToDailyDataJson = async (
|
||||||
null, // DCLoad not available for SodioHome
|
null, // DCLoad not available for SodioHome
|
||||||
'SODIOHOME_BATTERY_VOLTAGE'
|
'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',
|
'Battery.Soc',
|
||||||
product == 0 ? 'Battery.Temperature' : 'Battery.TemperatureCell1',
|
product == 0 ? 'Battery.Temperature' : 'Battery.TemperatureCell1',
|
||||||
|
|
@ -599,6 +643,46 @@ export const transformInputToDailyDataJson = async (
|
||||||
break;
|
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) {
|
} else if (category_index === 4) {
|
||||||
// Salimax/Salidomo: Custom logic for 'PvOnDc.Dc.Power'
|
// Salimax/Salidomo: Custom logic for 'PvOnDc.Dc.Power'
|
||||||
if (get(result, path) !== undefined) {
|
if (get(result, path) !== undefined) {
|
||||||
|
|
@ -651,6 +735,15 @@ export const transformInputToDailyDataJson = async (
|
||||||
chartOverview[category].magnitude = magnitude;
|
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.unit = '(%)';
|
||||||
chartOverview.soc.min = 0;
|
chartOverview.soc.min = 0;
|
||||||
chartOverview.soc.max = 100;
|
chartOverview.soc.max = 100;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export type ChecklistItem = {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 4, 5]);
|
export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 5]);
|
||||||
|
|
||||||
export const UPLOADABLE_SUBTASK_KEYS: ReadonlySet<string> = new Set([
|
export const UPLOADABLE_SUBTASK_KEYS: ReadonlySet<string> = new Set([
|
||||||
'checklistStep8Sub1',
|
'checklistStep8Sub1',
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@
|
||||||
"couplingType": "AC/DC-Kopplung",
|
"couplingType": "AC/DC-Kopplung",
|
||||||
"couplingAC": "AC-gekoppelt",
|
"couplingAC": "AC-gekoppelt",
|
||||||
"couplingDC": "DC-gekoppelt",
|
"couplingDC": "DC-gekoppelt",
|
||||||
|
"couplingMixed": "Gemischt",
|
||||||
"selectModel": "Modell auswählen...",
|
"selectModel": "Modell auswählen...",
|
||||||
"inverterN": "Wechselrichter {n}",
|
"inverterN": "Wechselrichter {n}",
|
||||||
"clusterN": "Cluster {n}",
|
"clusterN": "Cluster {n}",
|
||||||
|
|
@ -535,6 +536,11 @@
|
||||||
"stopDateTime": "Stoppdatum und -zeit (Startzeit < Stoppzeit)",
|
"stopDateTime": "Stoppdatum und -zeit (Startzeit < Stoppzeit)",
|
||||||
"installationSetup": "Installationseinrichtung",
|
"installationSetup": "Installationseinrichtung",
|
||||||
"batteryLimits": "Batteriegrenzwerte",
|
"batteryLimits": "Batteriegrenzwerte",
|
||||||
|
"relayControl": "Relaissteuerung",
|
||||||
|
"relay1": "Relais 1",
|
||||||
|
"relay2": "Relais 2",
|
||||||
|
"relay3": "Relais 3",
|
||||||
|
"relay4": "Relais 4",
|
||||||
"systemSettings": "Systemeinstellungen",
|
"systemSettings": "Systemeinstellungen",
|
||||||
"pvPerInverter": "PV pro Wechselrichter",
|
"pvPerInverter": "PV pro Wechselrichter",
|
||||||
"pvInInverter": "PV in Wechselrichter {number}",
|
"pvInInverter": "PV in Wechselrichter {number}",
|
||||||
|
|
@ -799,5 +805,10 @@
|
||||||
"checklistPhasePreparation": "Vorbereitung",
|
"checklistPhasePreparation": "Vorbereitung",
|
||||||
"checklistPhaseOnSite": "Vor Ort",
|
"checklistPhaseOnSite": "Vor Ort",
|
||||||
"checklistPhaseHandover": "Kundenübergabe",
|
"checklistPhaseHandover": "Kundenübergabe",
|
||||||
"checklistPhaseComplete": "Abgeschlossen"
|
"checklistPhaseComplete": "Abgeschlossen",
|
||||||
|
"activePowerPercent": "Wirkleistung (%)",
|
||||||
|
"minDischargeVoltageV": "Min. Entladespannung (V)",
|
||||||
|
"maxDischargeCurrentA": "Max. Entladestrom (A)",
|
||||||
|
"maxChargeCurrentA": "Max. Ladestrom (A)",
|
||||||
|
"maxChargeVoltageV": "Max. Ladespannung (V)"
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +78,7 @@
|
||||||
"couplingType": "AC/DC Coupling",
|
"couplingType": "AC/DC Coupling",
|
||||||
"couplingAC": "AC-coupled",
|
"couplingAC": "AC-coupled",
|
||||||
"couplingDC": "DC-coupled",
|
"couplingDC": "DC-coupled",
|
||||||
|
"couplingMixed": "Mixed",
|
||||||
"selectModel": "Select model...",
|
"selectModel": "Select model...",
|
||||||
"inverterN": "Inverter {n}",
|
"inverterN": "Inverter {n}",
|
||||||
"clusterN": "Cluster {n}",
|
"clusterN": "Cluster {n}",
|
||||||
|
|
@ -283,6 +284,11 @@
|
||||||
"stopDateTime": "Stop Date and Time (Start Time < Stop Time)",
|
"stopDateTime": "Stop Date and Time (Start Time < Stop Time)",
|
||||||
"installationSetup": "Installation Setup",
|
"installationSetup": "Installation Setup",
|
||||||
"batteryLimits": "Battery Limits",
|
"batteryLimits": "Battery Limits",
|
||||||
|
"relayControl": "Relay Control",
|
||||||
|
"relay1": "Relay 1",
|
||||||
|
"relay2": "Relay 2",
|
||||||
|
"relay3": "Relay 3",
|
||||||
|
"relay4": "Relay 4",
|
||||||
"systemSettings": "System Settings",
|
"systemSettings": "System Settings",
|
||||||
"pvPerInverter": "PV per Inverter",
|
"pvPerInverter": "PV per Inverter",
|
||||||
"pvInInverter": "PV in Inverter {number}",
|
"pvInInverter": "PV in Inverter {number}",
|
||||||
|
|
@ -547,5 +553,10 @@
|
||||||
"checklistPhasePreparation": "Preparation",
|
"checklistPhasePreparation": "Preparation",
|
||||||
"checklistPhaseOnSite": "On-site",
|
"checklistPhaseOnSite": "On-site",
|
||||||
"checklistPhaseHandover": "Customer handover",
|
"checklistPhaseHandover": "Customer handover",
|
||||||
"checklistPhaseComplete": "Complete"
|
"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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@
|
||||||
"couplingType": "Couplage AC/DC",
|
"couplingType": "Couplage AC/DC",
|
||||||
"couplingAC": "Couplage AC",
|
"couplingAC": "Couplage AC",
|
||||||
"couplingDC": "Couplage DC",
|
"couplingDC": "Couplage DC",
|
||||||
|
"couplingMixed": "Mixte",
|
||||||
"selectModel": "Sélectionner le modèle...",
|
"selectModel": "Sélectionner le modèle...",
|
||||||
"inverterN": "Onduleur {n}",
|
"inverterN": "Onduleur {n}",
|
||||||
"clusterN": "Cluster {n}",
|
"clusterN": "Cluster {n}",
|
||||||
|
|
@ -535,6 +536,11 @@
|
||||||
"stopDateTime": "Date et heure de fin (Début < Fin)",
|
"stopDateTime": "Date et heure de fin (Début < Fin)",
|
||||||
"installationSetup": "Configuration de l'installation",
|
"installationSetup": "Configuration de l'installation",
|
||||||
"batteryLimits": "Limites de la batterie",
|
"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",
|
"systemSettings": "Paramètres système",
|
||||||
"pvPerInverter": "PV par onduleur",
|
"pvPerInverter": "PV par onduleur",
|
||||||
"pvInInverter": "PV dans l'onduleur {number}",
|
"pvInInverter": "PV dans l'onduleur {number}",
|
||||||
|
|
@ -799,5 +805,10 @@
|
||||||
"checklistPhasePreparation": "Préparation",
|
"checklistPhasePreparation": "Préparation",
|
||||||
"checklistPhaseOnSite": "Sur site",
|
"checklistPhaseOnSite": "Sur site",
|
||||||
"checklistPhaseHandover": "Transfert client",
|
"checklistPhaseHandover": "Transfert client",
|
||||||
"checklistPhaseComplete": "Terminé"
|
"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)"
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +78,7 @@
|
||||||
"couplingType": "Accoppiamento AC/DC",
|
"couplingType": "Accoppiamento AC/DC",
|
||||||
"couplingAC": "Accoppiamento AC",
|
"couplingAC": "Accoppiamento AC",
|
||||||
"couplingDC": "Accoppiamento DC",
|
"couplingDC": "Accoppiamento DC",
|
||||||
|
"couplingMixed": "Misto",
|
||||||
"selectModel": "Seleziona modello...",
|
"selectModel": "Seleziona modello...",
|
||||||
"inverterN": "Inverter {n}",
|
"inverterN": "Inverter {n}",
|
||||||
"clusterN": "Cluster {n}",
|
"clusterN": "Cluster {n}",
|
||||||
|
|
@ -535,6 +536,11 @@
|
||||||
"stopDateTime": "Data e ora di fine (Inizio < Fine)",
|
"stopDateTime": "Data e ora di fine (Inizio < Fine)",
|
||||||
"installationSetup": "Configurazione dell'installazione",
|
"installationSetup": "Configurazione dell'installazione",
|
||||||
"batteryLimits": "Limiti della batteria",
|
"batteryLimits": "Limiti della batteria",
|
||||||
|
"relayControl": "Controllo relè",
|
||||||
|
"relay1": "Relè 1",
|
||||||
|
"relay2": "Relè 2",
|
||||||
|
"relay3": "Relè 3",
|
||||||
|
"relay4": "Relè 4",
|
||||||
"systemSettings": "Impostazioni di sistema",
|
"systemSettings": "Impostazioni di sistema",
|
||||||
"pvPerInverter": "PV per inverter",
|
"pvPerInverter": "PV per inverter",
|
||||||
"pvInInverter": "PV nell'inverter {number}",
|
"pvInInverter": "PV nell'inverter {number}",
|
||||||
|
|
@ -799,5 +805,10 @@
|
||||||
"checklistPhasePreparation": "Preparazione",
|
"checklistPhasePreparation": "Preparazione",
|
||||||
"checklistPhaseOnSite": "In sito",
|
"checklistPhaseOnSite": "In sito",
|
||||||
"checklistPhaseHandover": "Consegna cliente",
|
"checklistPhaseHandover": "Consegna cliente",
|
||||||
"checklistPhaseComplete": "Completato"
|
"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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue