Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import ocpp.cs._2015._10.StopTransactionRequest;
import org.jetbrains.annotations.Nullable;
import org.joda.time.DateTime;
import org.joda.time.base.AbstractInstant;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
Expand All @@ -45,6 +44,9 @@
@RequiredArgsConstructor
public class CentralSystemService16_ServiceValidator {

private static final DateTime MIN = new DateTime(0);
private static final DateTime MAX = new DateTime(Long.MAX_VALUE);

private final Clock clock;
private final Duration operationalDeltaForNow;

Expand Down Expand Up @@ -94,30 +96,52 @@
return new SteveException("meterStart is greater than meterStop");
}

return this.validateMeterValuesInternal(stopParams.getTransactionData(), stopParams.getTimestamp());
return this.validateMeterValuesInternal(stopParams.getTransactionData(), thisTx.getStartTimestamp(), stopParams.getTimestamp());

Check failure on line 99 in src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_ServiceValidator.java

View workflow job for this annotation

GitHub Actions / checkstyle

[checkstyle] reported by reviewdog 🐶 Line is longer than 120 characters (found 136). Raw Output: /github/workspace/./src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_ServiceValidator.java:99:0: error: Line is longer than 120 characters (found 136). (com.puppycrawl.tools.checkstyle.checks.sizes.LineLengthCheck)
}

public SteveException validateMeterValues(MeterValuesRequest params) {
if (params.getConnectorId() < 0) {
return new SteveException("MeterValues.connectorId must not be negative");
}

return this.validateMeterValuesInternal(params.getMeterValue(), null);
return this.validateMeterValuesInternal(params.getMeterValue(), null, null);
}

private SteveException validateMeterValuesInternal(List<MeterValue> meterValues, @Nullable DateTime stopTimestamp) {
private SteveException validateMeterValuesInternal(List<MeterValue> meterValues,
@Nullable DateTime startTimestamp,
@Nullable DateTime stopTimestamp) {
if (CollectionUtils.isEmpty(meterValues)) {
return null;
}

DateTime latest = meterValues.stream()
.map(MeterValue::getTimestamp)
.filter(java.util.Objects::nonNull)
.max(AbstractInstant::compareTo)
.orElse(null);
DateTime earliest = MAX;
DateTime latest = MIN;
DateTime prev = MIN;

// single pass: track earliest, latest, and check chronological order
for (MeterValue mv : meterValues) {
if (mv == null) {
continue;
}

DateTime ts = mv.getTimestamp();

// should not happen because of @NotNull
if (ts == null) {
return new SteveException("MeterValue.timestamp is empty");
}

// check timestamp monotonicity: timestamps should be non-decreasing
if (ts.isBefore(prev)) {
return new SteveException("MeterValue timestamps are not in chronological order");
}

// should not happen because of @NotNull
if (latest == null) {
if (ts.isBefore(earliest)) earliest = ts;

Check failure on line 139 in src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_ServiceValidator.java

View workflow job for this annotation

GitHub Actions / checkstyle

[checkstyle] reported by reviewdog 🐶 'if' construct must use '{}'s. Raw Output: /github/workspace/./src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_ServiceValidator.java:139:13: error: 'if' construct must use '{}'s. (com.puppycrawl.tools.checkstyle.checks.blocks.NeedBracesCheck)
if (ts.isAfter(latest)) latest = ts;

Check failure on line 140 in src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_ServiceValidator.java

View workflow job for this annotation

GitHub Actions / checkstyle

[checkstyle] reported by reviewdog 🐶 'if' construct must use '{}'s. Raw Output: /github/workspace/./src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_ServiceValidator.java:140:13: error: 'if' construct must use '{}'s. (com.puppycrawl.tools.checkstyle.checks.blocks.NeedBracesCheck)
prev = ts;
}

if (earliest == MAX || latest == MIN) {
return new SteveException("MeterValue.timestamp is empty");
}

Expand All @@ -129,6 +153,16 @@
return new SteveException("at least one MeterValue.timestamp is after stop.timestamp");
}

// allow the same operational delta tolerance for start timestamp check, since charge points
// may have slight clock drift and meter values can be sampled before the StartTransaction
// message is processed on the server side
if (startTimestamp != null) {
long deltaMillis = operationalDeltaForNow.toMillis();
if (earliest.getMillis() < startTimestamp.getMillis() - deltaMillis) {
return new SteveException("at least one MeterValue.timestamp is before start.timestamp");
}
}

return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.List;

/**
Expand Down Expand Up @@ -217,6 +218,94 @@ public void validateStop_transactionDataAfterStopTimestamp_returnsError() {
Assertions.assertEquals("at least one MeterValue.timestamp is after stop.timestamp", result.getMessage());
}

@Test
public void validateStop_transactionDataBeforeStartTimestamp_returnsError() {
// more than 5 minutes (operational delta) before start
var tx = tx("100", DateTime.parse("2026-02-17T09:00:00Z"), null, null, null);
var params = stopParams(DateTime.parse("2026-02-17T10:00:00Z"), "200")
.withTransactionData(List.of(meterValue("2026-02-17T08:54:59Z")));
var result = validator.validateStop(tx, params);

Assertions.assertNotNull(result);
Assertions.assertEquals("at least one MeterValue.timestamp is before start.timestamp", result.getMessage());
}

@Test
public void validateStop_transactionDataSlightlyBeforeStartTimestamp_isAllowed() {
// within 5 minutes (operational delta) before start — allowed for clock drift
var tx = tx("100", DateTime.parse("2026-02-17T09:00:00Z"), null, null, null);
var params = stopParams(DateTime.parse("2026-02-17T10:00:00Z"), "200")
.withTransactionData(List.of(meterValue("2026-02-17T08:55:01Z")));
var result = validator.validateStop(tx, params);

Assertions.assertNull(result);
}

@Test
public void validateStop_transactionDataAtStartTimestamp_isAllowed() {
var tx = tx("100", DateTime.parse("2026-02-17T09:00:00Z"), null, null, null);
var params = stopParams(DateTime.parse("2026-02-17T10:00:00Z"), "200")
.withTransactionData(List.of(meterValue("2026-02-17T09:00:00Z")));
var result = validator.validateStop(tx, params);

Assertions.assertNull(result);
}

@Test
public void validateMeterValues_timestampsOutOfOrder_returnsError() {
var result = validator.validateMeterValues(meterValuesParams(1, List.of(
meterValue("2026-02-17T10:00:00Z"),
meterValue("2026-02-17T09:00:00Z")
)));

Assertions.assertNotNull(result);
Assertions.assertEquals("MeterValue timestamps are not in chronological order", result.getMessage());
}

@Test
public void validateMeterValues_timestampsInOrder_returnsNull() {
var result = validator.validateMeterValues(meterValuesParams(1, List.of(
meterValue("2026-02-17T09:00:00Z"),
meterValue("2026-02-17T09:30:00Z"),
meterValue("2026-02-17T10:00:00Z")
)));

Assertions.assertNull(result);
}

@Test
public void validateMeterValues_sameTimestamps_returnsNull() {
var result = validator.validateMeterValues(meterValuesParams(1, List.of(
meterValue("2026-02-17T10:00:00Z"),
meterValue("2026-02-17T10:00:00Z")
)));

Assertions.assertNull(result);
}

@Test
public void validateMeterValues_nullElementsInList_doesNotThrowNPE() {
// null MeterValue elements should be filtered out, not cause NPE
var result = validator.validateMeterValues(meterValuesParams(1, Arrays.asList(
null,
meterValue("2026-02-17T09:00:00Z"),
null
)));

Assertions.assertNull(result);
}

@Test
public void validateMeterValues_allNullElements_returnsError() {
// when all elements are null, all timestamps are filtered out → treated as empty
var result = validator.validateMeterValues(meterValuesParams(1, Arrays.asList(
null, null
)));

Assertions.assertNotNull(result);
Assertions.assertEquals("MeterValue.timestamp is empty", result.getMessage());
}

private static StopTransactionRequest stopParams(DateTime stopTimestamp, String meterStop) {
return new StopTransactionRequest()
.withIdTag("tag-1")
Expand Down