diff --git a/CommHandler/CHANGELOG.md b/CommHandler/CHANGELOG.md
index c0ef67c4..919d721e 100644
--- a/CommHandler/CHANGELOG.md
+++ b/CommHandler/CHANGELOG.md
@@ -5,15 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## Unreleased
+## [unreleased]
## Added
- client for SGr declaration library
+- JSONata expressions as response query in REST / messaging data points and message filters
+- response or template query can be used in write operations
### Changed
-- requires updated driver implementations
+- requires updated driver implementations supporting driver API 2.4
+- requires `sgr-specification` 2.2
## [2.4.2] - 2025-10-09
diff --git a/CommHandler/build.gradle b/CommHandler/build.gradle
index 24d50e53..25c7607e 100644
--- a/CommHandler/build.gradle
+++ b/CommHandler/build.gradle
@@ -97,6 +97,7 @@ dependencies {
api group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.18.4'
implementation group: 'io.burt', name: 'jmespath-core', version: '0.6.0'
implementation group: 'io.burt', name: 'jmespath-jackson', version: '0.6.0'
+ implementation group: 'com.dashjoin', name: 'jsonata', version: '0.9.8'
implementation group: 'commons-io', name: 'commons-io', version: '2.15.1'
implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.19.0'
implementation group: 'jakarta.annotation', name: 'jakarta.annotation-api', version: '2.1.1'
diff --git a/CommHandler/src/main/java/com/smartgridready/communicator/common/helper/JsonHelper.java b/CommHandler/src/main/java/com/smartgridready/communicator/common/helper/JsonHelper.java
index d0223465..94ab882e 100644
--- a/CommHandler/src/main/java/com/smartgridready/communicator/common/helper/JsonHelper.java
+++ b/CommHandler/src/main/java/com/smartgridready/communicator/common/helper/JsonHelper.java
@@ -1,5 +1,6 @@
package com.smartgridready.communicator.common.helper;
+import com.dashjoin.jsonata.Jsonata;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -55,6 +56,32 @@ public static Value parseJsonResponse(String jmesPath, String jsonResp) throws G
JsonNode res = expression.search(jsonNode);
return JsonValue.of(res);
}
+ } catch (Exception e) {
+ throw new GenDriverException("Failed to parse JSON response", e);
+ }
+ }
+
+ /**
+ * Evaluates a JSONata expression on a JSON string and returns as SGr value.
+ * @param jsonataExpression the JSONata expression
+ * @param jsonResp the JSON string
+ * @return an instance of {@link JsonValue}
+ * @throws GenDriverException when an error occurred during parsing
+ */
+ public static Value parseJsonResponseWithJsonata(String jsonataExpression, String jsonResp) throws GenDriverException {
+
+ ObjectMapper mapper = new ObjectMapper();
+
+ try {
+ if (jsonataExpression != null && !jsonataExpression.trim().isEmpty()) {
+ var expression = Jsonata.jsonata(jsonataExpression);
+ var jsonObj = mapper.readValue(jsonResp, Object.class);
+ var result = expression.evaluate(jsonObj);
+ return JsonValue.of(result);
+ }
+
+ return JsonValue.of(mapper.readTree(jsonResp));
+
} catch (IOException e) {
throw new GenDriverException("Failed to parse JSON response", e);
}
diff --git a/CommHandler/src/main/java/com/smartgridready/communicator/messaging/impl/MessageFilterHandlerImpl.java b/CommHandler/src/main/java/com/smartgridready/communicator/messaging/impl/MessageFilterHandlerImpl.java
index e5a430f4..62dfc85d 100644
--- a/CommHandler/src/main/java/com/smartgridready/communicator/messaging/impl/MessageFilterHandlerImpl.java
+++ b/CommHandler/src/main/java/com/smartgridready/communicator/messaging/impl/MessageFilterHandlerImpl.java
@@ -60,6 +60,14 @@ public boolean isFilterMatch(String payload) {
} catch (GenDriverException e) {
return false; // no match
}
+ } else if (messageFilter.getJsonataFilter() != null) {
+ try {
+ var filter = messageFilter.getJsonataFilter();
+ regexMatch = filter.getMatchesRegex();
+ payloadValue = JsonHelper.parseJsonResponseWithJsonata(filter.getQuery(), payload);
+ } catch (GenDriverException e) {
+ return false; // no match
+ }
}
// regex matching for all filter types
@@ -75,7 +83,8 @@ public void validate() throws OperationNotSupportedException {
(messageFilter.getPlaintextFilter() != null) ||
(messageFilter.getRegexFilter() != null) ||
(messageFilter.getXpapathFilter() != null) ||
- (messageFilter.getJmespathFilter() != null)
+ (messageFilter.getJmespathFilter() != null) ||
+ (messageFilter.getJsonataFilter() != null)
) {
return;
}
diff --git a/CommHandler/src/main/java/com/smartgridready/communicator/messaging/impl/SGrMessagingDevice.java b/CommHandler/src/main/java/com/smartgridready/communicator/messaging/impl/SGrMessagingDevice.java
index ef009cca..b16cdb9c 100644
--- a/CommHandler/src/main/java/com/smartgridready/communicator/messaging/impl/SGrMessagingDevice.java
+++ b/CommHandler/src/main/java/com/smartgridready/communicator/messaging/impl/SGrMessagingDevice.java
@@ -51,6 +51,7 @@ public class SGrMessagingDevice extends SGrDeviceBase<
MessagingFunctionalProfile,
MessagingDataPoint> implements GenDeviceApi4Messaging {
+ @SuppressWarnings("unused")
private static final Logger LOG = LoggerFactory.getLogger(SGrMessagingDevice.class);
private static final long SYNC_READ_TIMEOUT_MSEC = 60000;
@@ -208,30 +209,14 @@ private Value getValueFromDevice(Map parameters, long timeoutMs,
.get() // returns Message
.getPayload();
- Optional queryOpt = Optional.ofNullable(dataPoint.getMessagingDataPointConfiguration())
- .map(MessagingDataPointConfiguration::getInMessage)
- .map(InMessage::getResponseQuery);
-
- Value value;
- if (queryOpt.isPresent()) {
- ResponseQuery responseQuery = queryOpt.get();
- if (responseQuery.getQueryType() != null && ResponseQueryType.JMES_PATH_EXPRESSION == responseQuery.getQueryType()) {
- value = JsonHelper.parseJsonResponse(responseQuery.getQuery(), response);
- } else if (responseQuery.getQueryType() != null && ResponseQueryType.JMES_PATH_MAPPING == responseQuery.getQueryType()) {
- value = JsonHelper.mapJsonResponse(responseQuery.getJmesPathMappings(), response);
- } else if (responseQuery.getQueryType() != null && ResponseQueryType.X_PATH_EXPRESSION == responseQuery.getQueryType()) {
- value = XPathHelper.parseXmlResponse(responseQuery.getQuery(), response);
- } else if (responseQuery.getQueryType() != null && ResponseQueryType.REGULAR_EXPRESSION == responseQuery.getQueryType()) {
- value = RegexHelper.query(responseQuery.getQuery(), response);
- } else if (responseQuery.getQueryType() != null) {
- throw new GenDriverException("Response query type " + responseQuery.getQueryType().name() + " not supported yet");
- } else {
- throw new GenDriverException("Response query type missing");
- }
- } else {
- // mapping device -> generic (only for plain string values)
- value = getMappedGenericValue(dataPoint.getMessagingDataPointConfiguration(), response);
- }
+
+ Value value = StringValue.of(response);
+
+ // value transformation using responseQuery
+ value = getTransformedGenericValue(dataPoint.getMessagingDataPointConfiguration(), value);
+
+ // mapping device -> generic (only for plain string values)
+ value = getMappedGenericValue(dataPoint.getMessagingDataPointConfiguration(), value);
// unit conversion before returning to client
return applyUnitConversion(dataPoint, value, SGrDeviceBase::multiply);
@@ -264,10 +249,13 @@ public void setVal(String profileName, String dataPointName, Value value)
value = applyUnitConversion(dataPoint, value, SGrDeviceBase::divide);
// mapping generic -> device
- String outValue = getMappedDeviceValue(dataPoint.getMessagingDataPointConfiguration(), value);
+ value = getMappedDeviceValue(dataPoint.getMessagingDataPointConfiguration(), value);
+
+ // value transformation using templateQuery
+ value = getTransformedDeviceValue(dataPoint.getMessagingDataPointConfiguration(), value);
// no regex here, string literal replacement is sufficient
- outMessageTemplate = outMessageTemplate.replace("[[value]]", outValue);
+ outMessageTemplate = outMessageTemplate.replace("[[value]]", value.getString());
messagingClient.sendSync(outMessageTopic, Message.of(outMessageTemplate));
}
@@ -308,35 +296,14 @@ private void transformIncomingMessage(
String response = Optional.ofNullable(msgReceiveResult.get().getPayload()).orElse("");
- Optional queryOpt = Optional.ofNullable(dataPoint.getMessagingDataPointConfiguration())
- .map(MessagingDataPointConfiguration::getInMessage)
- .map(InMessage::getResponseQuery);
-
try {
- Value value;
- if (queryOpt.isPresent()) {
- switch (queryOpt.get().getQueryType()) {
- case JMES_PATH_EXPRESSION:
- value = JsonHelper.parseJsonResponse(queryOpt.get().getQuery(), response);
- break;
- case JMES_PATH_MAPPING:
- value = JsonHelper.mapJsonResponse(queryOpt.get().getJmesPathMappings(), response);
- break;
- case X_PATH_EXPRESSION:
- value = XPathHelper.parseXmlResponse(queryOpt.get().getQuery(), response);
- break;
- case REGULAR_EXPRESSION:
- value = RegexHelper.query(queryOpt.get().getQuery(), response);
- break;
- default:
- throw new GenDriverException("Response query type " + queryOpt.get().getQueryType().name() + " not supported yet");
- }
- } else {
- // mapping device -> generic (only for plain string values)
- value = getMappedGenericValue(dataPoint.getMessagingDataPointConfiguration(), response);
- }
- LOG.debug("Received subscribed message on topic={}, filter={}, payload={}",
- inMessageTopic, queryOpt.isPresent() ? queryOpt.get().getQuery() : "none", response);
+ Value value = StringValue.of(response);
+
+ // value transformation using responseQuery
+ value = getTransformedGenericValue(dataPoint.getMessagingDataPointConfiguration(), value);
+
+ // mapping device -> generic (only for plain string values)
+ value = getMappedGenericValue(dataPoint.getMessagingDataPointConfiguration(), value);
// unit conversion before inserting into cache
value = applyUnitConversion(dataPoint, value, SGrDeviceBase::multiply);
@@ -425,40 +392,106 @@ private int countCacheRecords4Topic(String topic) {
return count.get();
}
- private static Value getMappedGenericValue(MessagingDataPointConfiguration dataPointConfiguration, String value) {
- String mappedValue = value;
+ private static Value getMappedGenericValue(MessagingDataPointConfiguration dataPointConfiguration, Value value) {
+ Value mappedValue = value;
List valueMappings = Optional.ofNullable(dataPointConfiguration)
.map(MessagingDataPointConfiguration::getInMessage)
.map(InMessage::getValueMapping)
- .map(MessagingValueMapping::getMapping).orElse(Collections.emptyList());
- for (ValueMapping mapping: valueMappings) {
- if (mappedValue.equals(mapping.getDeviceValue())) {
- mappedValue = mapping.getGenericValue();
- break;
- }
+ .map(MessagingValueMapping::getMapping)
+ .orElse(Collections.emptyList());
+
+ final String strVal = mappedValue.getString();
+ Optional mappingOpt = valueMappings.stream()
+ .filter(m -> strVal.equals(m.getDeviceValue()))
+ .findFirst();
+ if (mappingOpt.isPresent()) {
+ mappedValue = StringValue.of(mappingOpt.get().getGenericValue());
}
- return StringValue.of(mappedValue);
+ return mappedValue;
}
- private static String getMappedDeviceValue(MessagingDataPointConfiguration dataPointConfiguration, Value value) {
- String mappedValue = value.getString();
+ private static Value getMappedDeviceValue(MessagingDataPointConfiguration dataPointConfiguration, Value value) {
+ Value mappedValue = value;
List valueMappings = Optional.ofNullable(dataPointConfiguration)
.map(MessagingDataPointConfiguration::getWriteCmdMessage)
.map(OutMessage::getValueMapping)
- .map(MessagingValueMapping::getMapping).orElse(Collections.emptyList());
- for (ValueMapping mapping: valueMappings) {
- if (mappedValue.equals(mapping.getGenericValue())) {
- mappedValue = mapping.getDeviceValue();
- break;
- }
+ .map(MessagingValueMapping::getMapping)
+ .orElse(Collections.emptyList());
+
+ final String strVal = mappedValue.getString();
+ Optional mappingOpt = valueMappings.stream()
+ .filter(m -> strVal.equals(m.getGenericValue()))
+ .findFirst();
+ if (mappingOpt.isPresent()) {
+ mappedValue = StringValue.of(mappingOpt.get().getDeviceValue());
}
return mappedValue;
}
+ private static Value getTransformedGenericValue(MessagingDataPointConfiguration dataPointConfiguration, Value value) throws GenDriverException {
+ Value tmpValue = value;
+
+ ResponseQuery responseQuery = Optional.ofNullable(dataPointConfiguration)
+ .map(MessagingDataPointConfiguration::getInMessage)
+ .map(InMessage::getResponseQuery)
+ .orElse(null);
+
+ if (responseQuery != null) {
+ if (responseQuery.getQueryType() == null) {
+ throw new GenDriverException("Response query type missing");
+ }
+ if (ResponseQueryType.JMES_PATH_EXPRESSION == responseQuery.getQueryType()) {
+ tmpValue = JsonHelper.parseJsonResponse(responseQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.JMES_PATH_MAPPING == responseQuery.getQueryType()) {
+ tmpValue = JsonHelper.mapJsonResponse(responseQuery.getJmesPathMappings(), tmpValue.getString());
+ } else if (ResponseQueryType.X_PATH_EXPRESSION == responseQuery.getQueryType()) {
+ tmpValue = XPathHelper.parseXmlResponse(responseQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.REGULAR_EXPRESSION == responseQuery.getQueryType()) {
+ tmpValue = RegexHelper.query(responseQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.JSO_NATA_EXPRESSION == responseQuery.getQueryType()) {
+ tmpValue = JsonHelper.parseJsonResponseWithJsonata(responseQuery.getQuery(), tmpValue.getString());
+ } else {
+ throw new GenDriverException("Response query type " + responseQuery.getQueryType().name() + " not supported yet");
+ }
+ }
+
+ return tmpValue;
+ }
+
+ private static Value getTransformedDeviceValue(MessagingDataPointConfiguration dataPointConfiguration, Value value) throws GenDriverException {
+ Value tmpValue = value;
+
+ ResponseQuery templateQuery = Optional.ofNullable(dataPointConfiguration)
+ .map(MessagingDataPointConfiguration::getWriteCmdMessage)
+ .map(OutMessage::getTemplateQuery)
+ .orElse(null);
+
+ if (templateQuery != null) {
+ if (templateQuery.getQueryType() == null) {
+ throw new GenDriverException("Template query type missing");
+ }
+ if (ResponseQueryType.JMES_PATH_EXPRESSION == templateQuery.getQueryType()) {
+ tmpValue = JsonHelper.parseJsonResponse(templateQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.JMES_PATH_MAPPING == templateQuery.getQueryType()) {
+ tmpValue = JsonHelper.mapJsonResponse(templateQuery.getJmesPathMappings(), tmpValue.getString());
+ } else if (ResponseQueryType.X_PATH_EXPRESSION == templateQuery.getQueryType()) {
+ tmpValue = XPathHelper.parseXmlResponse(templateQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.REGULAR_EXPRESSION == templateQuery.getQueryType()) {
+ tmpValue = RegexHelper.query(templateQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.JSO_NATA_EXPRESSION == templateQuery.getQueryType()) {
+ tmpValue = JsonHelper.parseJsonResponseWithJsonata(templateQuery.getQuery(), tmpValue.getString());
+ } else {
+ throw new GenDriverException("Template query type " + templateQuery.getQueryType().name() + " not supported yet");
+ }
+ }
+
+ return tmpValue;
+ }
+
private static String substituteParameterPlaceholders(String template, Map substitutions) {
// this is for dynamic parameters
String convertedTemplate = template;
diff --git a/CommHandler/src/main/java/com/smartgridready/communicator/rest/http/authentication/BearerTokenAuthenticator.java b/CommHandler/src/main/java/com/smartgridready/communicator/rest/http/authentication/BearerTokenAuthenticator.java
index f5deb3b7..4e6f7384 100644
--- a/CommHandler/src/main/java/com/smartgridready/communicator/rest/http/authentication/BearerTokenAuthenticator.java
+++ b/CommHandler/src/main/java/com/smartgridready/communicator/rest/http/authentication/BearerTokenAuthenticator.java
@@ -18,8 +18,6 @@ WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWIS
package com.smartgridready.communicator.rest.http.authentication;
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
import com.smartgridready.driver.api.http.GenHttpResponse;
import com.smartgridready.communicator.rest.http.client.RestServiceClient;
import com.smartgridready.ns.v0.DeviceFrame;
@@ -27,17 +25,15 @@ WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWIS
import com.smartgridready.ns.v0.ResponseQueryType;
import com.smartgridready.ns.v0.RestApiInterfaceDescription;
import com.smartgridready.ns.v0.RestApiServiceCall;
+import com.smartgridready.communicator.common.helper.JsonHelper;
import com.smartgridready.communicator.rest.exception.RestApiServiceCallException;
+import com.smartgridready.driver.api.common.GenDriverException;
import com.smartgridready.driver.api.http.GenHttpClientFactory;
import com.smartgridready.communicator.rest.http.client.RestServiceClientUtils;
-import io.burt.jmespath.Expression;
-import io.burt.jmespath.JmesPath;
-import io.burt.jmespath.jackson.JacksonRuntime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
-import java.util.Optional;
/**
* Implements an HTTP authenticator using JWT bearer tokens.
@@ -111,31 +107,21 @@ private void requestBearerToken(RestServiceClient restServiceClient) throws IOEx
private String handleResponse(String result, RestApiServiceCall restApiServiceCall) throws IOException {
- Optional queryOpt = Optional.ofNullable(restApiServiceCall.getResponseQuery())
- .filter(responseQuery -> responseQuery.getQueryType() != null
- && responseQuery.getQueryType() == ResponseQueryType.JMES_PATH_EXPRESSION)
- .map(ResponseQuery::getQuery);
-
- if (queryOpt.isPresent()) {
- return parseResponse(result, queryOpt.get());
+ if (restApiServiceCall.getResponseQuery() != null) {
+ ResponseQuery responseQuery = restApiServiceCall.getResponseQuery();
+ if (responseQuery.getQueryType() != null) {
+ try {
+ if (ResponseQueryType.JMES_PATH_EXPRESSION == responseQuery.getQueryType()) {
+ return JsonHelper.parseJsonResponse(responseQuery.getQuery(), result).getString();
+ } else if (ResponseQueryType.JSO_NATA_EXPRESSION == responseQuery.getQueryType()) {
+ return JsonHelper.parseJsonResponseWithJsonata(responseQuery.getQuery(), result).getString();
+ }
+ } catch (GenDriverException e) {
+ throw new IOException("Failed to parse response", e);
+ }
+ }
}
+
return result;
}
-
- private String parseResponse(String jsonResp, String jmesPath) throws IOException {
-
- JmesPath path = new JacksonRuntime();
- Expression expression = path.compile(jmesPath);
-
- ObjectMapper mapper = new ObjectMapper();
- JsonNode jsonNode = mapper.readTree(jsonResp);
- JsonNode res = expression.search(jsonNode);
-
- if (res != null) {
- return res.asText();
- }
-
- LOG.error("Unable to extract bearer token from http response.");
- return null;
- }
}
diff --git a/CommHandler/src/main/java/com/smartgridready/communicator/rest/impl/SGrRestApiDevice.java b/CommHandler/src/main/java/com/smartgridready/communicator/rest/impl/SGrRestApiDevice.java
index 2360c77b..abb4a54b 100644
--- a/CommHandler/src/main/java/com/smartgridready/communicator/rest/impl/SGrRestApiDevice.java
+++ b/CommHandler/src/main/java/com/smartgridready/communicator/rest/impl/SGrRestApiDevice.java
@@ -55,7 +55,9 @@ WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWIS
import org.slf4j.LoggerFactory;
import java.io.IOException;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
@@ -163,14 +165,18 @@ private Value doReadWriteVal(RestApiDataPoint dataPoint, Value value, Properties
RestApiServiceCall serviceCall = evaluateRestApiServiceCall(dpDescription, rwpDirection);
if (value != null) {
+ // write operation
checkOutOfRange(new Value[]{value}, dataPoint);
value = applyUnitConversion(dataPoint, value, SGrDeviceBase::divide);
- // substitute value mappings generic -> device
- substitutions.put(
- "value",
- (serviceCall.getValueMapping() != null) ? getMappedDeviceValue(value.getString(), serviceCall.getValueMapping()) : value.getString()
- );
+ // value mappings generic -> device
+ value = getMappedDeviceValue(serviceCall, value);
+
+ // value transformation using responseQuery
+ value = getTransformedDeviceValue(serviceCall, value);
+
+ // substitute value
+ substitutions.put("value", value.getString());
}
// substitute default values of dynamic request parameters
if (null != dataPoint.getDataPoint().getParameterList()) {
@@ -187,10 +193,19 @@ private Value doReadWriteVal(RestApiDataPoint dataPoint, Value value, Properties
String response = handleServiceCall(restServiceClient, httpAuthenticator.isTokenRenewalSupported());
if (value == null) {
- value = handleServiceResponse(serviceCall, response);
+ // read operation
+ value = StringValue.of(response);
+
+ // value transformation using responseQuery
+ value = getTransformedGenericValue(serviceCall, value);
+
+ // value mappings device -> generic
+ value = getMappedGenericValue(serviceCall, value);
+
return applyUnitConversion(dataPoint, value, SGrDeviceBase::multiply);
}
+ // response of write operation
return StringValue.of(response);
}
throw new GenDriverException("Missing 'restApiDataPointConfiguration' description in device description XML file");
@@ -224,29 +239,6 @@ private String handleServiceCall(RestServiceClient serviceClient, boolean tryTok
}
}
- private Value handleServiceResponse(RestApiServiceCall restApiServiceCall, String response) throws GenDriverException {
-
- if (restApiServiceCall.getResponseQuery() != null) {
- ResponseQuery responseQuery = restApiServiceCall.getResponseQuery();
- if (responseQuery.getQueryType() != null && ResponseQueryType.JMES_PATH_EXPRESSION == responseQuery.getQueryType()) {
- return JsonHelper.parseJsonResponse(responseQuery.getQuery(), response);
- } else if (responseQuery.getQueryType() != null && ResponseQueryType.JMES_PATH_MAPPING == responseQuery.getQueryType()) {
- return JsonHelper.mapJsonResponse(responseQuery.getJmesPathMappings(), response);
- } else if (responseQuery.getQueryType() != null && ResponseQueryType.X_PATH_EXPRESSION == responseQuery.getQueryType()) {
- return XPathHelper.parseXmlResponse(responseQuery.getQuery(), response);
- } else if (responseQuery.getQueryType() != null && ResponseQueryType.REGULAR_EXPRESSION == responseQuery.getQueryType()) {
- return RegexHelper.query(responseQuery.getQuery(), response);
- }
- }
-
- // return plain response
-
- // substitute value mappings device -> generic
- return StringValue.of(
- (restApiServiceCall.getValueMapping() != null) ? getMappedGenericValue(response, restApiServiceCall.getValueMapping()) : response
- );
- }
-
private RestApiServiceCall evaluateRestApiServiceCall(RestApiDataPointConfiguration dataPointConfiguration, RwpDirections rwpDirections)
throws GenDriverException {
@@ -320,23 +312,91 @@ private RestApiInterfaceDescription getRestApiInterfaceDescription() {
return getRestApiInterface().getRestApiInterfaceDescription();
}
- private String getMappedDeviceValue(String genericValue, RestApiValueMapping valueMapping) {
- for (ValueMapping mapping: valueMapping.getMapping()) {
- if (genericValue.equals(mapping.getGenericValue())) {
- return mapping.getDeviceValue();
+ private Value getMappedDeviceValue(RestApiServiceCall serviceCall, Value value) {
+ Value mappedValue = value;
+
+ List valueMappings = Optional.ofNullable(serviceCall.getValueMapping())
+ .map(RestApiValueMapping::getMapping)
+ .orElse(Collections.emptyList());
+
+ final String strVal = mappedValue.getString();
+ Optional mappingOpt = valueMappings.stream()
+ .filter(m -> strVal.equals(m.getGenericValue()))
+ .findFirst();
+ if (mappingOpt.isPresent()) {
+ mappedValue = StringValue.of(mappingOpt.get().getDeviceValue());
+ }
+
+ return mappedValue;
+ }
+
+ private Value getMappedGenericValue(RestApiServiceCall serviceCall, Value value) {
+ Value mappedValue = value;
+
+ List valueMappings = Optional.ofNullable(serviceCall.getValueMapping())
+ .map(RestApiValueMapping::getMapping)
+ .orElse(Collections.emptyList());
+
+ final String strVal = mappedValue.getString();
+ Optional mappingOpt = valueMappings.stream()
+ .filter(m -> strVal.equals(m.getDeviceValue()))
+ .findFirst();
+ if (mappingOpt.isPresent()) {
+ mappedValue = StringValue.of(mappingOpt.get().getGenericValue());
+ }
+
+ return mappedValue;
+ }
+
+ private static Value getTransformedDeviceValue(RestApiServiceCall serviceCall, Value value) throws GenDriverException {
+ Value tmpValue = value;
+
+ ResponseQuery templateQuery = serviceCall.getResponseQuery();
+ if (templateQuery != null) {
+ if (templateQuery.getQueryType() == null) {
+ throw new GenDriverException("Template query type missing");
+ }
+ if (ResponseQueryType.JMES_PATH_EXPRESSION == templateQuery.getQueryType()) {
+ tmpValue = JsonHelper.parseJsonResponse(templateQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.JMES_PATH_MAPPING == templateQuery.getQueryType()) {
+ tmpValue = JsonHelper.mapJsonResponse(templateQuery.getJmesPathMappings(), tmpValue.getString());
+ } else if (ResponseQueryType.X_PATH_EXPRESSION == templateQuery.getQueryType()) {
+ tmpValue = XPathHelper.parseXmlResponse(templateQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.REGULAR_EXPRESSION == templateQuery.getQueryType()) {
+ tmpValue = RegexHelper.query(templateQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.JSO_NATA_EXPRESSION == templateQuery.getQueryType()) {
+ tmpValue = JsonHelper.parseJsonResponseWithJsonata(templateQuery.getQuery(), tmpValue.getString());
+ } else {
+ throw new GenDriverException("Template query type " + templateQuery.getQueryType().name() + " not supported yet");
}
}
- return genericValue;
+ return tmpValue;
}
- private String getMappedGenericValue(String deviceValue, RestApiValueMapping valueMapping) {
- for (ValueMapping mapping: valueMapping.getMapping()) {
- if (deviceValue.equals(mapping.getDeviceValue())) {
- return mapping.getGenericValue();
+ private static Value getTransformedGenericValue(RestApiServiceCall serviceCall, Value value) throws GenDriverException {
+ Value tmpValue = value;
+
+ ResponseQuery responseQuery = serviceCall.getResponseQuery();
+ if (responseQuery != null) {
+ if (responseQuery.getQueryType() == null) {
+ throw new GenDriverException("Response query type missing");
+ }
+ if (ResponseQueryType.JMES_PATH_EXPRESSION == responseQuery.getQueryType()) {
+ tmpValue = JsonHelper.parseJsonResponse(responseQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.JMES_PATH_MAPPING == responseQuery.getQueryType()) {
+ tmpValue = JsonHelper.mapJsonResponse(responseQuery.getJmesPathMappings(), tmpValue.getString());
+ } else if (ResponseQueryType.X_PATH_EXPRESSION == responseQuery.getQueryType()) {
+ tmpValue = XPathHelper.parseXmlResponse(responseQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.REGULAR_EXPRESSION == responseQuery.getQueryType()) {
+ tmpValue = RegexHelper.query(responseQuery.getQuery(), tmpValue.getString());
+ } else if (ResponseQueryType.JSO_NATA_EXPRESSION == responseQuery.getQueryType()) {
+ tmpValue = JsonHelper.parseJsonResponseWithJsonata(responseQuery.getQuery(), tmpValue.getString());
+ } else {
+ throw new GenDriverException("Response query type " + responseQuery.getQueryType().name() + " not supported yet");
}
}
- return deviceValue;
+ return tmpValue;
}
}
diff --git a/CommHandler/src/test/java/com/smartgridready/communicator/common/helper/JsonMapperTest.java b/CommHandler/src/test/java/com/smartgridready/communicator/common/helper/JsonMapperTest.java
index ac8cd44d..3248e360 100644
--- a/CommHandler/src/test/java/com/smartgridready/communicator/common/helper/JsonMapperTest.java
+++ b/CommHandler/src/test/java/com/smartgridready/communicator/common/helper/JsonMapperTest.java
@@ -2,7 +2,6 @@
import com.smartgridready.ns.v0.DeviceFrame;
import com.smartgridready.ns.v0.JMESPathMapping;
-import com.fasterxml.jackson.databind.JsonNode;
import com.smartgridready.communicator.common.impl.SGrDeviceBase;
import com.smartgridready.ns.v0.RestApiServiceCall;
import io.vavr.Tuple3;
@@ -15,6 +14,7 @@
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
class JsonMapperTest extends JsonMapperTestBase {
@@ -55,6 +55,24 @@ void mapSwisspower() throws Exception {
assertEquals(MAPPER.readTree(expectedJson).toString(), jsonResult);
}
+ @Test
+ void mapSwisspower_Jsonata() throws Exception {
+
+ Tuple3, Properties> device = createDevice("SGr_05_mmmm_dddd_Dynamic_Tariffs_Swisspower_Test_JSONata_V1.0.xml");
+ DeviceFrame deviceFrame = device._1;
+
+ String expression = getQueryExpression(deviceFrame);
+ assertNotNull(expression);
+
+ // map JSON
+ String jsonResponse = loadJson("TariffInSwisspower.json");
+ String expectedJson = loadJson("TariffOutSwisspower_withTariffName.json");
+
+ String jsonResult = JsonHelper.parseJsonResponseWithJsonata(expression, jsonResponse).getJson().toString();
+ LOG.debug("JSON result: {}", jsonResult);
+ assertEquals(MAPPER.readTree(expectedJson).toString(), jsonResult);
+ }
+
@Test
void mapGroupeE() throws Exception {
@@ -90,6 +108,24 @@ void mapGroupeE() throws Exception {
assertEquals(MAPPER.readTree(expectedJson).toString(), jsonResult);
}
+ @Test
+ void mapGroupeE_JSONata() throws Exception {
+
+ Tuple3, Properties> device = createDevice("SGr_05_mmmm_dddd_Dynamic_Tariffs_GroupeE_JSONata_V1.0.xml");
+ DeviceFrame deviceFrame = device._1;
+
+ String expression = getQueryExpression(deviceFrame);
+ assertNotNull(expression);
+
+ // map JSON
+ String jsonResponse = loadJson("TariffInGroupeE.json");
+ String expectedJson = loadJson("TariffOutGroupeE.json");
+
+ String jsonResult = JsonHelper.parseJsonResponseWithJsonata(expression, jsonResponse).getJson().toString();
+ LOG.debug("JSON result: {}", jsonResult);
+ assertEquals(MAPPER.readTree(expectedJson).toString(), jsonResult);
+ }
+
private static JMESPathMapping getJmesPathMapping(DeviceFrame deviceFrame) {
var restApiConfigurationContent = deviceFrame
@@ -108,7 +144,7 @@ private static JMESPathMapping getJmesPathMapping(DeviceFrame deviceFrame) {
throw new IllegalArgumentException("Device Frame does not contain RestApiServiceCall");
}
- private static String getJmesQueryExpression(DeviceFrame deviceFrame) {
+ private static String getQueryExpression(DeviceFrame deviceFrame) {
var restApiConfigurationContent = deviceFrame
.getInterfaceList()
diff --git a/CommHandler/src/test/resources/SGr_05_mmmm_dddd_Dynamic_Tariffs_GroupeE_JSONata_V1.0.xml b/CommHandler/src/test/resources/SGr_05_mmmm_dddd_Dynamic_Tariffs_GroupeE_JSONata_V1.0.xml
new file mode 100644
index 00000000..7df62697
--- /dev/null
+++ b/CommHandler/src/test/resources/SGr_05_mmmm_dddd_Dynamic_Tariffs_GroupeE_JSONata_V1.0.xml
@@ -0,0 +1,519 @@
+
+
+
+ Groupe E Dynamic Tariffs
+ Groupe E
+ 0
+
+ Published
+
+ 1.0.0
+ 2025-10-03
+ SGr/mkr
+ Finalization
+
+
+
+
+
+ The Vario tariff is a 15-minute dynamic day-ahead grid usage tariff.
+
+
+
Data Update Frequency: Day-ahead
+
Time of Publication: until 18:00
+
Binding Character: no change
+
Non-dynamic Elements: Only the grid usage tariff is dynamic.
+
+ ]]>
+
+ en
+ https://www.groupe-e.ch/de/strom/vario
+
+
+
+ Der Vario-Tarif ist ein 15-min dynamischer day-ahead Netznutzungstarif.
+
+
+
Häufigkeit der Datenaktualisierung: Day-ahead
+
Publikationszeitpunkt: bis spätestens 18:00 Uhr
+
Verbindlichkeit: keine Anpassung
+
Nicht-dynamische Elemente: Nur der Netznutzungstarif ist dynamisch.
+
+ ]]>
+
+ de
+ https://www.groupe-e.ch/de/strom/vario
+
+ DeviceInformation
+ false
+ 1.0.0
+ -
+ GroupeEDynamicTariffs
+ 5
+
+ 1
+ 0
+ 0
+
+ Verified
+
+
+ Link zur technischen Dokumentation:
+ ]]>
+
+ de
+ https://groupee.sharepoint.com/sites/MediaPoint/Supports%20publicitaires/02_Fiches%20produits/Smart%20meter/vario-integration-web-api-de.pdf?ga=1
+
+
+
+
+ tariff_name
+
+
+
+ vario_grid
+ Vario Grid
+
+
+ vario_plus
+ Vario PLUS
+
+
+ dt_plus
+ Double PLUS
+
+
+
+ vario_plus
+
+ The actual tariff product to get
+ en
+
+
+
+ Gewünschtes Tarif-Produkt
+ de
+
+
+
+
+
+
+
+ URI
+ https://api.tariffs.groupe-e.ch
+ NoSecurityScheme
+
+
+
+
+ DynamicTariff
+
+ 0
+ DynamicTariff
+ Supplier
+ 5
+
+ 1
+ 0
+ 0
+
+
+
+
+ The functional profile contains all the necessary information to retrieve a series of data points with information on tariff values every 15 minutes.
+
The prices are available on an online interface (WEB-API), which an energy management system will use to plan the operation of various devices.
+
+
This functional profile can only be provided by products with REST API interface.
+
The request for the tariffs defines the start time stamp and end time stamp of the interval the tariffs should be returned for.
+ Each entry in the resulting tariff list contains also the start time stamp and end time stamp of the sub intervals together with a list of entries with tariff unit and tariff value.
+
+ The schema for the JSON created by the REST API is
+
+
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Dynamic Tariffs",
+ "description": "JSON schema for the delivery of tariffs",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "start_timestamp": {
+ "description": "Start time stamp of the tariff interval",
+ "type": "string"
+ },
+ "end_timestamp": {
+ "description": "End time stamp of the tariff interval",
+ "type": "string"
+ },
+ "integrated": {
+ "description": "List of tariffs in that interval",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "component": {
+ "description": "The optional name of the tariff",
+ "type": "string"
+ },
+ "unit": {
+ "description": "The tariff unit for the price",
+ "type": "string"
+ },
+ "value": {
+ "description": "The tariff price value",
+ "type": "number"
+ }
+ },
+ "propertyNames": {
+ "type": "string",
+ "pattern": "^(component|unit|value)$"
+ },
+ "required": [
+ "unit",
+ "value"
+ ]
+ }
+ }
+ },
+ "propertyNames": {
+ "type": "string",
+ "pattern": "^(start_timestamp|end_timestamp|integrated)$"
+ },
+ "required": [
+ "start_timestamp",
+ "end_timestamp",
+ "integrated"
+ ]
+ }
+}
+
Das Funktionsprofil enthält alle nötigen Informationen, um eine Reihe von Datenpunkten mit Tarifinformationen alle 15 Minuten abzufragen.
+
Die Preise sind über ein Online-Interface (WEB-API) abrufbar, womit ein Energie-Management-System den Betrieb verschiedener Geräte zeitlich festlegt.
+
+
Dieses Funktionsprofil kann nur mit Produkten genutzt werden, die ein REST API unterstützen.
+
Die Tarif-Abfrage definiert den Start- und End-Zeitpunkt des Intervalls, für das die Tarife abgerufen werden.
+ Jedes Element der resultierenden Tarif-Liste enthält auch den Start- und End-Zeitpunkt der Sub-Intervalle, in Kombination mit einer Liste von Elementen mit Tarif-Einheit und -Preis.
+ ]]>
+
+ de
+
+
+
+
+
+ TariffSupply
+ R
+
+
+
+ NONE
+
+
+ start_timestamp
+
+
+
+
+
+ Begin of tariff periods to be queried. Format according ISO 8601: YYYY-MM-DDThh:mm:ssTZD.
+
+ en
+
+
+
+
+ Beginn der abzurufenden Tarifperiode. Format gem. ISO 8601: YYYY-MM-DDThh:mm:ssTZD.
+
+ de
+
+
+
+
+ end_timestamp
+
+
+
+
+
+ End of tariff periods to be queried. Format according ISO 8601: YYYY-MM-DDThh:mm:ssTZD.
+
+ en
+
+
+
+
+ Ende der abzurufenden Tarifperiode. Format gem. ISO 8601: YYYY-MM-DDThh:mm:ssTZD.
+
+ de
+
+
+
+
+
+
+ Tariff list - description see above
+ Parameters are
+
+
start_timestamp - ISO8601 date/time - the start time stamp of the requested interval
+
end_timestamp - ISO8601 date/time - the end time stamp of the requested interval
+
+ The JMES queries for the elements of the result structure are:
+
+
start_timestamp: [*].start_timestamp
+ start time stamp of this interval
+
end_timestamp: [*].end_timestamp
+ end time stamp of this interval
+
component: [*].integrated[*].component
+ optional name of the tariff
+
unit: [*].integrated[*].unit
+ unit for the tariff
+
value: [*].integrated[*].value
+ price for the tariff
+
+ ]]>
+
+ en
+
+
+
+ Tarif-Liste - Beschreibung siehe oben
+ Parameter sind
+
+
start_timestamp - ISO8601 date/time - Start-Zeitpunkt des abzufragenden Intervalls
+
end_timestamp - ISO8601 date/time - End-Zeitpunkt des abzufragenden Intervalls
+
+ Die JMES-Queries der einzelnen Elemente der Ausgabe-Datenstruktur sind:
+
+
start_timestamp: [*].start_timestamp
+ Start-Zeitpunkt des Intervalls
+
end_timestamp: [*].end_timestamp
+ End-Zeitpunkt des Intervalls
+
component: [*].integrated[*].component
+ Optionaler Name des Tarifs
+
unit: [*].integrated[*].unit
+ Einheit des Tarifs
+
value: [*].integrated[*].value
+ Preis des Tarifs
+
+ ]]>
+
+ de
+
+
+
+ Maps the {{tariff_type}} property to match the integrated property required by SGr.
+
This EID is a template for actual provider tariffs!
+ ]]>
+
+ en
+ https://www.dynamische-stromtarife.ch/
+
+
+
+ Stellt dynamische Strompreise von ESIT/Swisspower bereit.
+
Diese EID ist eine Vorlage für konkrete Stromanbieter-Tarife!
+ ]]>
+
+ de
+ https://www.dynamische-stromtarife.ch/
+
+ DeviceInformation
+ false
+ 1.0.0
+ -
+ SwisspowerDynamicTariffs
+ 5
+
+ 1
+ 0
+ 0
+
+ Verified
+
+
+
+ tariff_type
+
+
+
+ electricity
+ Electricity
+
+
+ grid
+ Grid
+
+
+ integrated
+ Electricity with grid fees included
+
+
+ dso
+ DSO
+
+
+ feed_in
+ Feed-in
+
+
+
+ integrated
+
+ The tariff type to get
+ en
+
+
+
+ Gewünschter Tarif-Typ
+ de
+
+
+
+
+ metering_code
+
+
+
+
+
+ Metering point identifier of the customer
+ en
+
+
+
+ Identifikation des Messpunkts beim Kunden
+ de
+
+
+
+
+ token
+
+
+
+
+
+ Access token for the web service
+ en
+
+
+
+ Access-Token des Web-Service
+ de
+
+
+
+
+
+
+
+ URI
+ https://esit-test.code-fabrik.ch
+ NoSecurityScheme
+
+
+
+
+ DynamicTariff
+
+ 0
+ DynamicTariff
+ Supplier
+ 5
+
+ 1
+ 0
+ 0
+
+
+
+
+ The functional profile contains all the necessary information to retrieve a series of data points with information on tariff values every 15 minutes.
+
The prices are available on an online interface (WEB-API), which an energy management system will use to plan the operation of various devices.
+
+
This functional profile can only be provided by products with REST API interface.
+
The request for the tariffs defines the start time stamp and end time stamp of the interval the tariffs should be returned for.
+ Each entry in the resulting tariff list contains also the start time stamp and end time stamp of the sub intervals together with a list of entries with tariff unit and tariff value.
+
+ The schema for the JSON created by the REST API is
+
+
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Dynamic Tariffs",
+ "description": "JSON schema for the delivery of tariffs",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "start_timestamp": {
+ "description": "Start time stamp of the tariff interval",
+ "type": "string"
+ },
+ "end_timestamp": {
+ "description": "End time stamp of the tariff interval",
+ "type": "string"
+ },
+ "integrated": {
+ "description": "List of tariffs in that interval",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "component": {
+ "description": "The optional name of the tariff",
+ "type": "string"
+ },
+ "unit": {
+ "description": "The tariff unit for the price",
+ "type": "string"
+ },
+ "value": {
+ "description": "The tariff price value",
+ "type": "number"
+ }
+ },
+ "propertyNames": {
+ "type": "string",
+ "pattern": "^(component|unit|value)$"
+ },
+ "required": [
+ "unit",
+ "value"
+ ]
+ }
+ }
+ },
+ "propertyNames": {
+ "type": "string",
+ "pattern": "^(start_timestamp|end_timestamp|integrated)$"
+ },
+ "required": [
+ "start_timestamp",
+ "end_timestamp",
+ "integrated"
+ ]
+ }
+}
+
Das Funktionsprofil enthält alle nötigen Informationen, um eine Reihe von Datenpunkten mit Tarifinformationen alle 15 Minuten abzufragen.
+
Die Preise sind über ein Online-Interface (WEB-API) abrufbar, womit ein Energie-Management-System den Betrieb verschiedener Geräte zeitlich festlegt.
+
+
Dieses Funktionsprofil kann nur mit Produkten genutzt werden, die ein REST API unterstützen.
+
Die Tarif-Abfrage definiert den Start- und End-Zeitpunkt des Intervalls, für das die Tarife abgerufen werden.
+ Jedes Element der resultierenden Tarif-Liste enthält auch den Start- und End-Zeitpunkt der Sub-Intervalle, in Kombination mit einer Liste von Elementen mit Tarif-Einheit und -Preis.
+ ]]>
+
+ de
+
+
+
+
+
+ TariffSupply
+ R
+
+
+
+ NONE
+
+
+ start_timestamp
+
+
+
+
+
+ Begin of tariff periods to be queried. Format according ISO 8601: YYYY-MM-DDThh:mm:ssTZD.
+
+ en
+
+
+
+
+ Beginn der abzurufenden Tarifperiode. Format gem. ISO 8601: YYYY-MM-DDThh:mm:ssTZD.
+
+ de
+
+
+
+
+ end_timestamp
+
+
+
+
+
+ End of tariff periods to be queried. Format according ISO 8601: YYYY-MM-DDThh:mm:ssTZD.
+
+ en
+
+
+
+
+ Ende der abzurufenden Tarifperiode. Format gem. ISO 8601: YYYY-MM-DDThh:mm:ssTZD.
+
+ de
+
+
+
+
+
+
+ Tariff list - description see above
+ Parameters are
+
+
start_timestamp - ISO8601 date/time - the start time stamp of the requested interval
+
end_timestamp - ISO8601 date/time - the end time stamp of the requested interval
+
+ The JMES queries for the elements of the result structure are:
+
+
start_timestamp: [*].start_timestamp
+ start time stamp of this interval
+
end_timestamp: [*].end_timestamp
+ end time stamp of this interval
+
component: [*].integrated[*].component
+ optional name of the tariff
+
unit: [*].integrated[*].unit
+ unit for the tariff
+
value: [*].integrated[*].value
+ price for the tariff
+
+ ]]>
+
+ en
+
+
+
+ Tarif-Liste - Beschreibung siehe oben
+ Parameter sind
+
+
start_timestamp - ISO8601 date/time - Start-Zeitpunkt des abzufragenden Intervalls
+
end_timestamp - ISO8601 date/time - End-Zeitpunkt des abzufragenden Intervalls
+
+ Die JMES-Queries der einzelnen Elemente der Ausgabe-Datenstruktur sind:
+
+
start_timestamp: [*].start_timestamp
+ Start-Zeitpunkt des Intervalls
+
end_timestamp: [*].end_timestamp
+ End-Zeitpunkt des Intervalls
+
component: [*].integrated[*].component
+ Optionaler Name des Tarifs
+
unit: [*].integrated[*].unit
+ Einheit des Tarifs
+
value: [*].integrated[*].value
+ Preis des Tarifs
+
+ ]]>
+
+ de
+
+
+
+ Maps the {{tariff_type}} property to match the integrated property required by SGr.
+ ]]>
+
+ en
+
+
+
+ JSON_object
+
+
+
+ Accept
+ application/json
+
+
+ Authorization
+ Bearer {{token}}
+
+
+ GET
+ /api/v1/metering_code
+
+
+ tariff_type
+ {{tariff_type}}
+
+
+ metering_code
+ {{metering_code}}
+
+
+ start_timestamp
+ [[start_timestamp]]
+
+
+ end_timestamp
+ [[end_timestamp]]
+
+
+
+ JSONataExpression
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file