Skip to content

Commit 5ede161

Browse files
stamateviorelclaude
authored andcommitted
[ocpp] Detect and handle rejection of measurands by charger
Some of meter values configured by user may not be available to be read through MeterValues. As there is no way to detect it before hand updated logic attempts to find charger supported measures and ignore rest. PR: #147 Signed-off-by: Stamate Viorel <stamate.viorel@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 19ca476)
1 parent 8428386 commit 5ede161

2 files changed

Lines changed: 204 additions & 4 deletions

File tree

bundles/org.connectorio.addons.binding.ocpp/src/main/java/org/connectorio/addons/binding/ocpp/internal/server/adapter/MeterValuesConfigAdapter.java

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
import eu.chargetime.ocpp.model.core.BootNotificationConfirmation;
2121
import eu.chargetime.ocpp.model.core.BootNotificationRequest;
2222
import eu.chargetime.ocpp.model.core.ChangeConfigurationRequest;
23+
import eu.chargetime.ocpp.model.core.ChangeConfigurationConfirmation;
24+
import eu.chargetime.ocpp.model.core.ConfigurationStatus;
25+
import java.util.Map;
2326
import java.util.UUID;
27+
import java.util.concurrent.ConcurrentHashMap;
2428
import org.connectorio.addons.binding.ocpp.internal.OcppSender;
2529
import org.connectorio.addons.binding.ocpp.internal.server.ChargerReference;
2630
import org.connectorio.addons.binding.ocpp.internal.server.OcppChargerSessionRegistry;
@@ -36,6 +40,15 @@ public class MeterValuesConfigAdapter extends CoreEventHandlerAdapter {
3640
private final String sampledData;
3741
private final int clockAlignedInterval;
3842

43+
/**
44+
* Package-scoped for testability. Keyed by charger serial: the measurand list this charger has
45+
* last accepted. OCPP 1.6 has no way to enumerate a charger's supported measurands up front — the
46+
* only signal that one is unsupported is the ChangeConfiguration Reject itself — so the accepted
47+
* set is discovered once by elimination and reused on every later (re)connect instead of
48+
* re-discovering it from scratch each time.
49+
*/
50+
final Map<String, String> acceptedMeasurands = new ConcurrentHashMap<>();
51+
3952
public MeterValuesConfigAdapter(OcppChargerSessionRegistry sessionRegistry, OcppSender sender,
4053
int sampleInterval, String sampledData, int clockAlignedInterval) {
4154
this.sessionRegistry = sessionRegistry;
@@ -51,20 +64,63 @@ public BootNotificationConfirmation handleBootNotificationRequest(UUID sessionIn
5164
if (reference == null) {
5265
return null;
5366
}
67+
String measurands = acceptedMeasurands.getOrDefault(reference.getSerial(), sampledData);
5468
apply(reference, "MeterValueSampleInterval", Integer.toString(sampleInterval));
55-
apply(reference, "MeterValuesSampledData", sampledData);
56-
apply(reference, "MeterValuesAlignedData", sampledData);
69+
apply(reference, "MeterValuesSampledData", measurands);
70+
apply(reference, "MeterValuesAlignedData", measurands);
5771
apply(reference, "ClockAlignedDataInterval", Integer.toString(clockAlignedInterval));
5872
return null;
5973
}
6074

6175
private void apply(ChargerReference reference, String key, String value) {
76+
boolean isMeterMeasurandKey = "MeterValuesSampledData".equals(key) || "MeterValuesAlignedData".equals(key);
6277
sender.send(reference, new ChangeConfigurationRequest(key, value)).whenComplete((confirmation, ex) -> {
6378
if (ex != null) {
6479
logger.warn("ChangeConfiguration[{}] for {} failed: {}", key, reference, ex.getMessage());
65-
} else {
66-
logger.debug("ChangeConfiguration[{}={}] for {}: {}", key, value, reference, confirmation);
80+
return;
81+
}
82+
if (isMeterMeasurandKey && confirmation instanceof ChangeConfigurationConfirmation
83+
&& ((ChangeConfigurationConfirmation) confirmation).getStatus() == ConfigurationStatus.Rejected) {
84+
String stripped = stripLast(value);
85+
if (!stripped.equals(value) && !stripped.isEmpty()) {
86+
logger.info("Charger {} rejected ChangeConfiguration[{}={}] — retrying with one fewer measurand: {}",
87+
reference, key, value, stripped);
88+
apply(reference, key, stripped);
89+
return;
90+
}
91+
logger.warn("Charger {} rejected ChangeConfiguration[{}] down to a single measurand; giving up",
92+
reference, key);
93+
return;
94+
}
95+
if (isMeterMeasurandKey) {
96+
acceptedMeasurands.put(reference.getSerial(), value);
6797
}
98+
logger.debug("ChangeConfiguration[{}={}] for {}: {}", key, value, reference, confirmation);
6899
});
69100
}
101+
102+
/**
103+
* Return {@code value} with its last comma-separated measurand removed (remaining tokens
104+
* trimmed), or {@code value} unchanged if it is null/empty. Trial-based elimination: OCPP 1.6
105+
* gives no signal on which measurand a Reject was actually about, so candidates are tried by
106+
* dropping one at a time rather than by any hardcoded fragile-measurand list — no measurand name
107+
* is baked into the binding.
108+
*/
109+
static String stripLast(String value) {
110+
if (value == null || value.isEmpty()) {
111+
return value;
112+
}
113+
String[] tokens = value.split(",");
114+
if (tokens.length <= 1) {
115+
return "";
116+
}
117+
StringBuilder sb = new StringBuilder();
118+
for (int i = 0; i < tokens.length - 1; i++) {
119+
if (sb.length() > 0) {
120+
sb.append(',');
121+
}
122+
sb.append(tokens[i].trim());
123+
}
124+
return sb.toString();
125+
}
70126
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
* Copyright (C) 2022-2022 ConnectorIO Sp. z o.o.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
*/
18+
package org.connectorio.addons.binding.ocpp.internal.server.adapter;
19+
20+
import static org.assertj.core.api.Assertions.assertThat;
21+
import static org.mockito.Mockito.mock;
22+
import static org.mockito.Mockito.when;
23+
24+
import eu.chargetime.ocpp.model.Confirmation;
25+
import eu.chargetime.ocpp.model.Request;
26+
import eu.chargetime.ocpp.model.core.BootNotificationRequest;
27+
import eu.chargetime.ocpp.model.core.ChangeConfigurationConfirmation;
28+
import eu.chargetime.ocpp.model.core.ChangeConfigurationRequest;
29+
import eu.chargetime.ocpp.model.core.ConfigurationStatus;
30+
import java.util.ArrayList;
31+
import java.util.List;
32+
import java.util.UUID;
33+
import java.util.concurrent.CompletableFuture;
34+
import java.util.concurrent.CompletionStage;
35+
import org.connectorio.addons.binding.ocpp.internal.OcppSender;
36+
import org.connectorio.addons.binding.ocpp.internal.server.ChargerReference;
37+
import org.connectorio.addons.binding.ocpp.internal.server.OcppChargerSessionRegistry;
38+
import org.junit.jupiter.api.Test;
39+
40+
class MeterValuesConfigAdapterTest {
41+
42+
@Test
43+
void stripsTheLastMeasurand() {
44+
assertThat(MeterValuesConfigAdapter.stripLast(
45+
"Energy.Active.Import.Register,Power.Active.Import,Current.Import,Voltage"))
46+
.isEqualTo("Energy.Active.Import.Register,Power.Active.Import,Current.Import");
47+
}
48+
49+
@Test
50+
void stripsDownToEmptyOnceOnlyOneMeasurandIsLeft() {
51+
assertThat(MeterValuesConfigAdapter.stripLast("Voltage")).isEmpty();
52+
}
53+
54+
@Test
55+
void handlesWhitespaceAroundCommas() {
56+
assertThat(MeterValuesConfigAdapter.stripLast("Voltage, Temperature, Current.Import"))
57+
.isEqualTo("Voltage,Temperature");
58+
}
59+
60+
@Test
61+
void handlesNullAndEmpty() {
62+
assertThat(MeterValuesConfigAdapter.stripLast(null)).isNull();
63+
assertThat(MeterValuesConfigAdapter.stripLast("")).isEmpty();
64+
}
65+
66+
/**
67+
* Fake sender: Rejects a MeterValuesSampledData/AlignedData ChangeConfiguration while its value
68+
* still contains {@code rejectedMeasurand}, Accepts everything else. Stands in for a charger that
69+
* only supports a subset of the configured measurands, without hardcoding which subset.
70+
*/
71+
private static OcppSender rejectingSenderFor(String rejectedMeasurand, List<String> sentMeasurandValues) {
72+
return new OcppSender() {
73+
@Override
74+
@SuppressWarnings("unchecked")
75+
public <T extends Confirmation> CompletionStage<T> send(ChargerReference reference, Request request) {
76+
ChangeConfigurationRequest change = (ChangeConfigurationRequest) request;
77+
if ("MeterValuesSampledData".equals(change.getKey()) || "MeterValuesAlignedData".equals(change.getKey())) {
78+
sentMeasurandValues.add(change.getValue());
79+
}
80+
ConfigurationStatus status = change.getValue() != null && change.getValue().contains(rejectedMeasurand)
81+
? ConfigurationStatus.Rejected : ConfigurationStatus.Accepted;
82+
return (CompletionStage<T>) CompletableFuture.completedFuture(new ChangeConfigurationConfirmation(status));
83+
}
84+
};
85+
}
86+
87+
@Test
88+
void negotiatesDownOnRejectAndCachesTheAcceptedSetPerCharger() {
89+
ChargerReference charx = new ChargerReference("charx");
90+
UUID session = UUID.randomUUID();
91+
OcppChargerSessionRegistry registry = mock(OcppChargerSessionRegistry.class);
92+
when(registry.getCharger(session)).thenReturn(charx);
93+
94+
List<String> sentMeasurandValues = new ArrayList<>();
95+
MeterValuesConfigAdapter adapter = new MeterValuesConfigAdapter(registry,
96+
rejectingSenderFor("Temperature", sentMeasurandValues), 30,
97+
"Energy.Active.Import.Register,Temperature", 30);
98+
99+
adapter.handleBootNotificationRequest(session, new BootNotificationRequest());
100+
101+
assertThat(adapter.acceptedMeasurands.get("charx")).isEqualTo("Energy.Active.Import.Register");
102+
}
103+
104+
@Test
105+
void aReconnectStartsFromTheCachedAcceptedSetInsteadOfRenegotiating() {
106+
ChargerReference charx = new ChargerReference("charx");
107+
UUID session = UUID.randomUUID();
108+
OcppChargerSessionRegistry registry = mock(OcppChargerSessionRegistry.class);
109+
when(registry.getCharger(session)).thenReturn(charx);
110+
111+
List<String> sentMeasurandValues = new ArrayList<>();
112+
MeterValuesConfigAdapter adapter = new MeterValuesConfigAdapter(registry,
113+
rejectingSenderFor("Temperature", sentMeasurandValues), 30,
114+
"Energy.Active.Import.Register,Temperature", 30);
115+
116+
adapter.handleBootNotificationRequest(session, new BootNotificationRequest()); // first boot: negotiates down
117+
adapter.handleBootNotificationRequest(session, new BootNotificationRequest()); // reconnect: reuses the cache
118+
119+
// First boot tries the full configured list for both measurand keys, rejected both times, then
120+
// retries each down to the already-accepted set. The reconnect goes straight to that set for
121+
// both keys, first try, no further negotiation.
122+
assertThat(sentMeasurandValues).containsExactly(
123+
"Energy.Active.Import.Register,Temperature", "Energy.Active.Import.Register",
124+
"Energy.Active.Import.Register,Temperature", "Energy.Active.Import.Register",
125+
"Energy.Active.Import.Register", "Energy.Active.Import.Register");
126+
}
127+
128+
@Test
129+
void aChargerThatAcceptsTheFullListIsCachedTooSoLaterBootsSkipRenegotiation() {
130+
ChargerReference wallbox = new ChargerReference("wallbox");
131+
UUID session = UUID.randomUUID();
132+
OcppChargerSessionRegistry registry = mock(OcppChargerSessionRegistry.class);
133+
when(registry.getCharger(session)).thenReturn(wallbox);
134+
135+
List<String> sentMeasurandValues = new ArrayList<>();
136+
MeterValuesConfigAdapter adapter = new MeterValuesConfigAdapter(registry,
137+
rejectingSenderFor("Temperature", sentMeasurandValues), 30,
138+
"Energy.Active.Import.Register,Voltage", 30);
139+
140+
adapter.handleBootNotificationRequest(session, new BootNotificationRequest());
141+
142+
assertThat(adapter.acceptedMeasurands.get("wallbox")).isEqualTo("Energy.Active.Import.Register,Voltage");
143+
}
144+
}

0 commit comments

Comments
 (0)