diff --git a/entsoe/README.md b/entsoe/README.md new file mode 100644 index 0000000..9e1dcb3 --- /dev/null +++ b/entsoe/README.md @@ -0,0 +1,39 @@ +# ENTSO-E + +## Introduction + +[ENTSO-E](https://www.entsoe.eu/data/transparency-platform/) is a European transparency platform for the energy market that provides +a large collection of energy related data. + +This agent uses their [REST API](https://documenter.getpostman.com/view/7009892/2s93JtP3F6#3b383df0-ada2-49fe-9a50-98b1bb201c6b) to retrieve the `Energy Prices` (document type A44) data. + +## Prerequisites + +Accessing the REST API requires a security token. You must create an account and request such a token for your own usage. +To do so, follow the process described at [How to get security token? – Transparency Platform](https://transparencyplatform.zendesk.com/hc/en-us/articles/12845911031188-How-to-get-security-token). + +## Agent usage + +### ENTSO-E Agent + +To get access to the data, add an ENTSO-E agent to your configuration. +On the agent, fill-in the `Security token` attribute with the token you got from ENTSO-E (see [Prerequisites](#prerequisites) above). +You can optionally add the `Polling millis` attribute to set the polling frequency. It defaults to 1h. + +### Getting the data + +Retrieved pricing data is stored as predicted datapoints of any compatible asset attribute in your configuration. +Note: Compatible means capable of storing positive or negative decimal numbers i.e. the attribute type must be Number or Big Number. +If the attribute is of a different type a warning log will be generated, predicted datapoints will still be generated +but inconsistencies in the system will occur (e.g. errors while trying to graph the attribute). + +On an attribute of type `Number`, add an `Agent link` configuration (MetaItem). +Select the ENTSO-E agent created above, a `Zone` field will appear. +This indicates the region to fetch data for and must be an [Energy Identification Codes](https://www.entsoe.eu/data/energy-identification-codes-eic/). +[EIC Approved codes](https://www.entsoe.eu/data/energy-identification-codes-eic/eic-approved-codes/) provides a complete list of the available code. +Tip: when searching, be sure to select "All Codes" for the "EIC Type Code" criteria. +You'll for instance find that `10YNL----------L` is the code to use for The Netherlands. + +Make sure to also add the `Has predicted data points` configuration to your attribute. + +![](img/ENTSO-E-agent-link.png) diff --git a/entsoe/build.gradle b/entsoe/build.gradle new file mode 100644 index 0000000..d86e579 --- /dev/null +++ b/entsoe/build.gradle @@ -0,0 +1,87 @@ +apply plugin: "groovy" +apply plugin: "java-library" +apply plugin: "maven-publish" +apply plugin: "signing" + +base { + archivesName = "openremote-${project.name}-extension" +} + +dependencies { + api "io.openremote:openremote-manager:$openremoteVersion" + api "io.openremote:openremote-model:$openremoteVersion" + + api "org.jboss.resteasy:resteasy-client-api:$resteasyVersion" + api "org.jboss.resteasy:resteasy-jaxb-provider:$resteasyVersion" + + testImplementation "io.openremote:openremote-test:$openremoteVersion" +} + +jar { + from sourceSets.main.allJava +} + +javadoc { + failOnError = false +} + +java { + withJavadocJar() + withSourcesJar() +} + +publishing { + publications { + maven(MavenPublication) { + group = "io.openremote.extension" + artifactId = "openremote-${project.name}-extension" + from components.java + pom { + name = 'OpenRemote ENTSO-E extension' + description = 'Adds the ENTSO-E extension' + url = 'https://github.com/openremote/extensions' + licenses { + license { + name = 'GNU Affero General Public License v3.0' + url = 'https://www.gnu.org/licenses/agpl-3.0.en.html' + } + } + developers { + developer { + id = 'developers' + name = 'Developers' + email = 'developers@openremote.io' + organization = 'OpenRemote' + organizationUrl = 'https://openremote.io' + } + } + scm { + connection = 'scm:git:git://github.com/openremote/extensions.git' + developerConnection = 'scm:git:ssh://github.com:openremote/extensions.git' + url = 'https://github.com/openremote/extensions/tree/main' + } + } + } + } + + repositories { + maven { + if (!version.endsWith('-LOCAL')) { + credentials { + username = findProperty("publishUsername") + password = findProperty("publishPassword") + } + } + url = version.endsWith('-LOCAL') ? layout.buildDirectory.dir('repo') : version.endsWith('-SNAPSHOT') ? findProperty("snapshotsRepoUrl") : findProperty("releasesRepoUrl") + } + } +} + +signing { + def signingKey = findProperty("signingKey") + def signingPassword = findProperty("signingPassword") + if (signingKey && signingPassword) { + useInMemoryPgpKeys(signingKey, signingPassword) + sign publishing.publications.maven + } +} diff --git a/entsoe/img/ENTSO-E-agent-link.png b/entsoe/img/ENTSO-E-agent-link.png new file mode 100644 index 0000000..c7095b7 Binary files /dev/null and b/entsoe/img/ENTSO-E-agent-link.png differ diff --git a/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgent.java b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgent.java new file mode 100644 index 0000000..b763253 --- /dev/null +++ b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgent.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.entsoe.agent.protocol; + +import jakarta.persistence.Entity; +import org.openremote.model.asset.agent.Agent; +import org.openremote.model.asset.agent.AgentDescriptor; +import org.openremote.model.value.AttributeDescriptor; +import org.openremote.model.value.ValueType; + +import java.util.Optional; + +@Entity +public class EntsoeAgent extends Agent { + + public static final AgentDescriptor DESCRIPTOR = new AgentDescriptor<>( + EntsoeAgent.class, EntsoeProtocol.class, EntsoeAgentLink.class); + + public static final AttributeDescriptor SECURITY_TOKEN = new AttributeDescriptor<>("securityToken", ValueType.TEXT); + + public static final AttributeDescriptor BASE_URL = new AttributeDescriptor<>("baseURL", ValueType.TEXT).withOptional(true); + + public EntsoeAgent() { + } + + public EntsoeAgent(String name) { + super(name); + } + + @Override + public EntsoeProtocol getProtocolInstance() { + return new EntsoeProtocol(this); + } + + public Optional getSecurityToken() { + return getAttributes().getValue(SECURITY_TOKEN); + } + + public EntsoeAgent setSecurityToken(String value) { + getAttributes().getOrCreate(SECURITY_TOKEN).setValue(value); + return this; + } + + public Optional getBaseURL() { + return getAttributes().getValue(BASE_URL); + } + + public EntsoeAgent setBaseURL(String value) { + getAttributes().getOrCreate(BASE_URL).setValue(value); + return this; + } +} diff --git a/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgentLink.java b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgentLink.java new file mode 100644 index 0000000..39ef128 --- /dev/null +++ b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgentLink.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.entsoe.agent.protocol; + +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import org.openremote.model.asset.agent.AgentLink; + +public class EntsoeAgentLink extends AgentLink { + + @NotNull + @JsonPropertyDescription("Energy Identification Code of zone to fetch data for") + @Pattern(regexp = "^\\d{2}[A-Z][A-Z0-9-]{12}[A-Z0-9]$") + private String zone; + + // For Hydrators + public EntsoeAgentLink() { + } + + public EntsoeAgentLink(String id) { + super(id); + } + + public String getZone() { + return zone; + } + + public void setZone(String zone) { + this.zone = zone; + } +} diff --git a/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgentModelProvider.java b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgentModelProvider.java new file mode 100644 index 0000000..a6d3c7d --- /dev/null +++ b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgentModelProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.entsoe.agent.protocol; + +import org.openremote.model.AssetModelProvider; + +public class EntsoeAgentModelProvider implements AssetModelProvider { + + @Override + public boolean useAutoScan() { + return true; + } +} diff --git a/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocol.java b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocol.java new file mode 100644 index 0000000..5b41433 --- /dev/null +++ b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocol.java @@ -0,0 +1,460 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.entsoe.agent.protocol; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.Unmarshaller; +import org.jboss.resteasy.client.jaxrs.ResteasyClient; +import org.openremote.agent.protocol.AbstractProtocol; +import org.openremote.model.Container; +import org.openremote.model.asset.agent.ConnectionStatus; +import org.openremote.model.attribute.Attribute; +import org.openremote.model.attribute.AttributeEvent; +import org.openremote.model.attribute.AttributeRef; +import org.openremote.model.datapoint.ValueDatapoint; +import org.openremote.model.syslog.SyslogCategory; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamReader; +import java.io.StringReader; +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.openremote.container.web.WebTargetBuilder.createClient; +import static org.openremote.model.syslog.SyslogCategory.PROTOCOL; + +public class EntsoeProtocol extends AbstractProtocol { + + private static final Logger LOG = SyslogCategory.getLogger(PROTOCOL, EntsoeProtocol.class); + private static final DateTimeFormatter PERIOD_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmm").withZone(ZoneId.of("UTC")); + private static final DateTimeFormatter ENTSOE_DATETIME_FORMATTER = new DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd'T'HH:mm") + .optionalStart().appendPattern(":ss").optionalEnd() + .appendOffsetId() + .toFormatter(); + public static final String PROTOCOL_DISPLAY_NAME = "ENTSO-E"; + private static final AtomicReference client = new AtomicReference<>(); + private static final JAXBContext PUBLICATION_MARKET_DOCUMENT_CONTEXT = createPublicationMarketDocumentContext(); + private static final ThreadLocal PUBLICATION_UNMARSHALLER = ThreadLocal.withInitial(EntsoeProtocol::createPublicationUnmarshaller); + private static final ThreadLocal XML_INPUT_FACTORY = ThreadLocal.withInitial(EntsoeProtocol::createSecureXmlInputFactory); + + // Initial delay to allow system to populate agent links + private static final int INITIAL_POLLING_DELAY_MILLIS = 3000; // 3 seconds + private static final int DEFAULT_POLLING_MILLIS = 3600000; // 1 hour + + protected ScheduledFuture pollingFuture; + + public EntsoeProtocol(EntsoeAgent agent) { + super(agent); + initClient(); + } + + @Override + protected void doStart(Container container) throws Exception { + setConnectionStatus(ConnectionStatus.CONNECTING); + + if (agent.getSecurityToken().isEmpty()) { + setConnectionStatus(ConnectionStatus.ERROR); + LOG.warning("Security token is not set"); + return; + } + + if (!healthCheck()) { + setConnectionStatus(ConnectionStatus.ERROR); + LOG.warning("Could not reach ENTSO-E API, either API is unavailable or security token is invalid"); + return; + } + + restartPollingWithInitialDelay(); + + setConnectionStatus(ConnectionStatus.CONNECTED); + } + + @Override + protected void doStop(Container container) throws Exception { + // Cancel the polling task + if (pollingFuture != null) { + pollingFuture.cancel(true); + } + } + + @Override + protected void doLinkAttribute(String assetId, Attribute attribute, EntsoeAgentLink agentLink) throws RuntimeException { + if (!attribute.getType().getType().isAssignableFrom(BigDecimal.class) && !attribute.getType().getType().isAssignableFrom(Double.class)) { + LOG.warning("Linked attribute " + attribute.getName() + " of asset " + assetId + " not of supported type. Predicted data points will still be generated but inconsistent behaviour could occur."); + } + restartPollingWithInitialDelay(); + } + + @Override + protected void doUnlinkAttribute(String assetId, Attribute attribute, EntsoeAgentLink agentLink) { + // Do nothing, attributes that have been unlinked will not be processed anymore when we next trigger + } + + protected synchronized void restartPollingWithInitialDelay() { + if (scheduledExecutorService == null) { + return; + } + + if (pollingFuture != null) { + pollingFuture.cancel(false); + } + + int pollingMillis = agent.getPollingMillis().orElse(DEFAULT_POLLING_MILLIS); + pollingFuture = scheduledExecutorService.scheduleAtFixedRate( + this::runScheduledUpdate, + INITIAL_POLLING_DELAY_MILLIS, + pollingMillis, + TimeUnit.MILLISECONDS + ); + } + + protected void runScheduledUpdate() { + try { + updateAllLinkedAttributes(); + } catch (RuntimeException e) { + LOG.log(Level.WARNING, e, () -> "Scheduled ENTSO-E polling failed; keeping schedule active"); + } + } + + @Override + protected void doLinkedAttributeWrite(EntsoeAgentLink agentLink, AttributeEvent event, Object processedValue) { + // If some external source wants to write the current value of the attribute, we're OK with that + // and relay the event with AgentService as source so it goes through. + assetService.sendAttributeEvent(event); + } + + @Override + public String getProtocolName() { + return PROTOCOL_DISPLAY_NAME; + } + + @Override + public String getProtocolInstanceUri() { + return "https://transparency.entsoe.eu/"; + } + + protected void updateAllLinkedAttributes() { + LOG.fine("Updating all linked attributes with pricing information from ENTSO-E"); + + if (getLinkedAttributes().isEmpty()) { + LOG.fine("No linked attributes found, skipping pricing data update"); + return; + } + + Map>> attributesByZone = collectLinkedAttributesByZone(); + + attributesByZone.forEach((zone, linkedAttributesForZone) -> { + PublicationMarketDocument document = fetchPricingInformation(buildApiUrl(zone)); + applyPricingInformation(zone, document, linkedAttributesForZone); + }); + } + + protected Map>> collectLinkedAttributesByZone() { + Map>> attributesByZone = new LinkedHashMap<>(); + + getLinkedAttributes().forEach((attributeRef, attribute) -> { + EntsoeAgentLink agentLink = agent.getAgentLink(attribute); + attributesByZone.computeIfAbsent(agentLink.getZone(), ignored -> new ArrayList<>()) + .add(Map.entry(attributeRef, attribute)); + }); + + return attributesByZone; + } + + protected void applyPricingInformation(String zone, PublicationMarketDocument document, List> linkedAttributesForZone) { + if (document == null) { + linkedAttributesForZone.forEach(entry -> + LOG.warning(() -> "No ENTSO-E publication document returned for attribute: " + entry.getKey() + " in zone: " + zone)); + return; + } + + List> predictedDatapoints = buildPredictedDatapoints(document); + if (predictedDatapoints.isEmpty()) { + linkedAttributesForZone.forEach(entry -> + LOG.warning(() -> "No datapoints built from ENTSO-E publication document for attribute: " + entry.getKey() + " in zone: " + zone)); + return; + } + + linkedAttributesForZone.forEach(entry -> { + AttributeRef attributeRef = entry.getKey(); + Attribute attribute = entry.getValue(); + LOG.fine("Updating pricing information data for attribute " + attribute.getName()); + predictedDatapointService.updateValues(attributeRef.getId(), attributeRef.getName(), predictedDatapoints); + }); + } + + protected String buildApiUrl(String zone) { + String securityToken = agent.getSecurityToken().orElseThrow(() -> new IllegalStateException("Security token is not set")); + String baseUrl = agent.getBaseURL().orElse("https://web-api.tp.entsoe.eu/api"); + Instant start = timerService.getNow(); + Instant end = start.plus(1, ChronoUnit.DAYS); + + return UriBuilder.fromUri(baseUrl) + .queryParam("documentType", "A44") + .queryParam("contract_MarketAgreement.type", "A01") + .queryParam("periodStart", PERIOD_FORMATTER.format(start)) + .queryParam("periodEnd", PERIOD_FORMATTER.format(end)) + .queryParam("in_Domain", zone) + .queryParam("out_Domain", zone) + .queryParam("securityToken", securityToken) + .build() + .toString(); + } + + /** + * Perform a health check by sending a request to the ENTSO-E API + * + * @return true if the health check is successful, false otherwise + */ + protected boolean healthCheck() { + String apiUrl = buildApiUrl("10YBE----------2"); + try (Response response = client.get().target(apiUrl).request(jakarta.ws.rs.core.MediaType.APPLICATION_XML).get()) { + if (response.getStatus() != 200) { + LOG.warning("Health check failed with status: " + response.getStatus()); + return false; + } else { + return true; + } + } catch (Exception e) { + LOG.log(Level.WARNING, e, () -> "Failed to perform health check"); + return false; + } + } + + /** + * Fetch the pricing data from the ENTSO-E API for the given API URL + * + * @param apiUrl the API URL + * @return the PublicationMarketDocument from the API + */ + protected PublicationMarketDocument fetchPricingInformation(String apiUrl) { + try (Response response = client.get().target(apiUrl).request(javax.ws.rs.core.MediaType.APPLICATION_XML).get()) { + if (response.getStatus() == 200) { + String responseXml = response.readEntity(String.class); + EntsoeXmlMeta xmlMeta = parseEntsoeXmlMeta(responseXml); + + if ("Publication_MarketDocument".equals(xmlMeta.rootElement)) { + return unmarshalPublicationMarketDocument(responseXml); + } + + if ("Acknowledgement_MarketDocument".equals(xmlMeta.rootElement)) { + String reason = xmlMeta.reasonText != null ? xmlMeta.reasonText : "no reason provided"; + LOG.info("No ENTSO-E pricing data available: " + reason); + return null; + } + + LOG.warning("Unsupported ENTSO-E response XML root element: " + xmlMeta.rootElement); + return null; + } else if (response.getStatus() == 401) { + LOG.warning("API request was unauthorized, either the security token is invalid or does not provide access to the API"); + return null; + } else { + LOG.warning("API request failed with status: " + response.getStatus()); + return null; + } + } catch (Exception e) { + LOG.log(Level.WARNING, e, () -> "Failed to fetch pricing data"); + return null; + } + } + + protected static XMLInputFactory createSecureXmlInputFactory() { + try { + XMLInputFactory factory = XMLInputFactory.newFactory(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + factory.setXMLResolver((publicId, systemId, baseUri, namespace) -> null); + return factory; + } catch (Exception e) { + throw new IllegalStateException("Failed to initialise secure XMLInputFactory", e); + } + } + + protected static JAXBContext createPublicationMarketDocumentContext() { + try { + return JAXBContext.newInstance(PublicationMarketDocument.class); + } catch (Exception e) { + throw new IllegalStateException("Failed to initialise PublicationMarketDocument JAXB context", e); + } + } + + protected static Unmarshaller createPublicationUnmarshaller() { + try { + return PUBLICATION_MARKET_DOCUMENT_CONTEXT.createUnmarshaller(); + } catch (Exception e) { + throw new IllegalStateException("Failed to initialise PublicationMarketDocument unmarshaller", e); + } + } + + protected PublicationMarketDocument unmarshalPublicationMarketDocument(String xml) throws Exception { + XMLStreamReader reader = XML_INPUT_FACTORY.get().createXMLStreamReader(new StringReader(xml)); + try { + return (PublicationMarketDocument) PUBLICATION_UNMARSHALLER.get().unmarshal(reader); + } finally { + reader.close(); + } + } + + protected EntsoeXmlMeta parseEntsoeXmlMeta(String xml) throws Exception { + XMLStreamReader reader = XML_INPUT_FACTORY.get().createXMLStreamReader(new StringReader(xml)); + String rootElement = null; + StringBuilder reasonTextBuilder = new StringBuilder(); + boolean inReason = false; + boolean inReasonText = false; + + try { + while (reader.hasNext()) { + int event = reader.next(); + + if (event == XMLStreamConstants.START_ELEMENT) { + String localName = reader.getLocalName(); + if (rootElement == null) { + rootElement = localName; + } + if ("Reason".equals(localName)) { + inReason = true; + } else if (inReason && "text".equals(localName)) { + inReasonText = true; + } + } else if (event == XMLStreamConstants.CHARACTERS && inReasonText) { + String text = reader.getText(); + if (text != null) { + reasonTextBuilder.append(text); + } + } else if (event == XMLStreamConstants.END_ELEMENT) { + String localName = reader.getLocalName(); + if ("text".equals(localName)) { + inReasonText = false; + } else if ("Reason".equals(localName)) { + inReason = false; + } + } + } + } finally { + reader.close(); + } + + String reasonText = reasonTextBuilder.toString().replaceAll("\\s+", " ").trim(); + if (reasonText.isEmpty()) { + reasonText = null; + } + + return new EntsoeXmlMeta(rootElement, reasonText); + } + + protected static class EntsoeXmlMeta { + protected final String rootElement; + protected final String reasonText; + + protected EntsoeXmlMeta(String rootElement, String reasonText) { + this.rootElement = rootElement; + this.reasonText = reasonText; + } + } + + protected List> buildPredictedDatapoints(PublicationMarketDocument document) { + List> values = new ArrayList<>(); + long nowMillis = timerService.getCurrentTimeMillis(); + if (document.getTimeSeries() == null || document.getTimeSeries().isEmpty()) { + return values; + } + + for (PublicationMarketDocument.TimeSeries timeSeries : document.getTimeSeries()) { + if (timeSeries.getPeriods() == null || timeSeries.getPeriods().isEmpty()) { + continue; + } + + for (PublicationMarketDocument.Period period : timeSeries.getPeriods()) { + if (period == null || period.getPoints() == null || period.getPoints().isEmpty()) { + continue; + } + + PublicationMarketDocument.PeriodTimeInterval timeInterval = period.getTimeInterval() != null + ? period.getTimeInterval() + : document.getPeriodTimeInterval(); + if (timeInterval == null || timeInterval.getStart() == null || period.getResolution() == null) { + continue; + } + + final Instant start; + final Duration resolution; + try { + start = parseEntsoeInstant(timeInterval.getStart()); + resolution = Duration.parse(period.getResolution()); + } catch (Exception e) { + LOG.log(Level.WARNING, e, () -> "Could not parse ENTSO-E timeseries time data"); + continue; + } + + period.getPoints().stream() + .filter(point -> point.getPosition() != null && point.getPosition() > 0 && point.getPriceAmount() != null) + .sorted(Comparator.comparingInt(PublicationMarketDocument.Point::getPosition)) + .forEach(point -> { + long timestamp = start + .plus(resolution.multipliedBy(point.getPosition() - 1L)) + .toEpochMilli(); + if (timestamp >= nowMillis) { + values.add(new ValueDatapoint<>(timestamp, point.getPriceAmount())); + } + }); + } + } + + values.sort(Comparator.comparingLong(ValueDatapoint::getTimestamp)); + return values; + } + + protected Instant parseEntsoeInstant(String value) { + try { + return Instant.parse(value); + } catch (Exception ignored) { + return OffsetDateTime.parse(value, ENTSOE_DATETIME_FORMATTER).toInstant(); + } + } + + protected static void initClient() { + synchronized (client) { + if (client.get() == null) { + client.set(createClient(org.openremote.container.Container.SCHEDULED_EXECUTOR)); + } + } + } + +} diff --git a/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/PublicationMarketDocument.java b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/PublicationMarketDocument.java new file mode 100644 index 0000000..e555427 --- /dev/null +++ b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/PublicationMarketDocument.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.entsoe.agent.protocol; + +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlRootElement; + +import java.math.BigDecimal; +import java.util.List; + + +@XmlRootElement(name = "Publication_MarketDocument", namespace = PublicationMarketDocument.NS) +@XmlAccessorType(XmlAccessType.FIELD) +public class PublicationMarketDocument { + + public static final String NS = "urn:iec62325.351:tc57wg16:451-3:publicationdocument:7:3"; + + @XmlElement(name = "period.timeInterval", namespace = NS) + private PeriodTimeInterval periodTimeInterval; + + @XmlElement(name = "TimeSeries", namespace = NS) + private List timeSeries; + + @XmlAccessorType(XmlAccessType.FIELD) + public static class PeriodTimeInterval { + @XmlElement(name = "start", namespace = NS) + private String start; + + @XmlElement(name = "end", namespace = NS) + private String end; + + public String getStart() { return start; } + public String getEnd() { return end; } + } + + @XmlAccessorType(XmlAccessType.FIELD) + public static class TimeSeries { + + @XmlElement(name = "Period", namespace = NS) + private List periods; + + public List getPeriods() { return periods; } + } + + @XmlAccessorType(XmlAccessType.FIELD) + public static class Period { + + @XmlElement(name = "timeInterval", namespace = NS) + private PeriodTimeInterval timeInterval; + + @XmlElement(name = "resolution", namespace = NS) + private String resolution; // e.g. PT15M + + @XmlElement(name = "Point", namespace = NS) + private List points; + + public PeriodTimeInterval getTimeInterval() { return timeInterval; } + public String getResolution() { return resolution; } + public List getPoints() { return points; } + } + + @XmlAccessorType(XmlAccessType.FIELD) + public static class Point { + + @XmlElement(name = "position", namespace = NS) + private Integer position; + + @XmlElement(name = "price.amount", namespace = NS) + private BigDecimal priceAmount; + + public Integer getPosition() { return position; } + public BigDecimal getPriceAmount() { return priceAmount; } + } + + public List getTimeSeries() { return timeSeries; } + public PeriodTimeInterval getPeriodTimeInterval() { return periodTimeInterval; } +} diff --git a/entsoe/src/main/resources/META-INF/services/org.openremote.model.AssetModelProvider b/entsoe/src/main/resources/META-INF/services/org.openremote.model.AssetModelProvider new file mode 100644 index 0000000..5162cde --- /dev/null +++ b/entsoe/src/main/resources/META-INF/services/org.openremote.model.AssetModelProvider @@ -0,0 +1 @@ +org.openremote.extension.entsoe.agent.protocol.EntsoeAgentModelProvider diff --git a/entsoe/src/test/groovy/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocolTest.groovy b/entsoe/src/test/groovy/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocolTest.groovy new file mode 100644 index 0000000..531fc4c --- /dev/null +++ b/entsoe/src/test/groovy/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocolTest.groovy @@ -0,0 +1,1264 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.openremote.extension.entsoe.agent.protocol + +import jakarta.validation.Validation +import jakarta.ws.rs.client.ClientRequestContext +import jakarta.ws.rs.client.ClientRequestFilter +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.openremote.manager.agent.AgentService +import org.openremote.manager.asset.AssetStorageService +import org.openremote.manager.datapoint.AssetPredictedDatapointService +import org.openremote.model.asset.agent.ConnectionStatus +import org.openremote.model.asset.impl.ThingAsset +import org.openremote.model.attribute.Attribute +import org.openremote.model.attribute.AttributeRef +import org.openremote.model.attribute.MetaItem +import org.openremote.model.datapoint.ValueDatapoint +import org.openremote.test.ManagerContainerTrait +import spock.lang.IgnoreIf +import spock.lang.Issue +import spock.lang.Shared +import spock.lang.Specification +import spock.util.concurrent.PollingConditions + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.TimeUnit + +import static org.openremote.model.Constants.MASTER_REALM +import static org.openremote.model.value.MetaItemType.AGENT_LINK +import static org.openremote.model.value.ValueType.NUMBER + +@IgnoreIf({ System.getenv("GITHUB_ACTIONS") == "true" }) +@Issue("https://github.com/openremote/openremote/issues/2599") +class EntsoeProtocolTest extends Specification implements ManagerContainerTrait { + private static final String DATASET_START = "2026-02-16T23:00:00.000Z" + private static final String BEFORE_DATASET_START = "2026-02-16T22:00:00.000Z" + + @Shared + Map requestCountByZone = [:].withDefault { 0 } + @Shared + def validator = Validation.buildDefaultValidatorFactory().validator + @Shared + List> requestLog = Collections.synchronizedList(new ArrayList<>()) + + @Shared + def mockServer = new ClientRequestFilter() { + + @Override + void filter(ClientRequestContext requestContext) throws IOException { + // We want the call to take at least 1ms or we get issues with attribute events being ignored as outdated + Thread.sleep(1) + def requestUri = requestContext.uri + + if (requestUri.host == "web-api.tp.entsoe.eu" && requestUri.path == "/api") { + def queryParams = [:] + requestUri.query?.split("&")?.each { pair -> + def parts = pair.split("=", 2) + if (parts.length == 2) { + queryParams[parts[0]] = parts[1] + } + } + def zone = queryParams["in_Domain"] + requestLog.add([ + zone : queryParams["in_Domain"], + periodStart: queryParams["periodStart"], + periodEnd : queryParams["periodEnd"] + ]) + + def content + if (zone == "10YBE----------2") { + content = ''' + + 68f443af488b4a0a9c4442bddd8c59c0 + 1 + A44 + 10X1001A1001A450 + A32 + 10X1001A1001A450 + A33 + 2026-02-17T09:45:56Z + + 2026-02-16T23:00Z + 2026-02-17T23:00Z + + + 1 + A01 + A62 + 10YBE----------2 + 10YBE----------2 + A01 + EUR + MWH + A03 + + + 2026-02-16T23:00Z + 2026-02-17T23:00Z + + PT15M + + 1 + 73.24 + + + 2 + 69.79 + + + 3 + 65.84 + + + 4 + 65.05 + + + + +''' + } else if (zone == "10YNL----------L") { + content = ''' + + bbf443af488b4a0a9c4442bddd8c59c9 + 1 + A44 + 2026-02-17T09:45:56Z + + 2026-02-16T23:00Z + 2026-02-17T23:00Z + + + 1 + A03 + + + 2026-02-16T23:00Z + 2026-02-17T23:00Z + + PT15M + + 1 + 81.11 + + + 2 + 82.22 + + + 3 + 83.33 + + + 4 + 84.44 + + + + +''' + } else if (zone == "10YER----------X") { + requestCountByZone[zone] = requestCountByZone[zone] + 1 + + if (requestCountByZone[zone] == 1) { + content = ''' + + + 2026-02-16T23:00Z + 2026-02-17T23:00Z + + + + + 2026-02-16T23:00Z + 2026-02-17T23:00Z + + PT15M + + 1 + 91.11 + + + 2 + 92.22 + + + 3 + 93.33 + + + 4 + 94.44 + + + + +''' + } else { + requestContext.abortWith(Response.status(500).build()) + return + } + } else if (zone == "10YNDATA-------A") { + content = ''' + + 0985f391-49af-4 + 2026-03-11T06:29:10Z + + 999 + No matching data found for Data item ENERGY_PRICES [12.1.D]. + + +''' + } else if (zone == "10YXXE---------X") { + content = ''' + +]> + + + 2026-02-16T23:00Z + 2026-02-17T23:00Z + + + + + 2026-02-16T23:00Z + 2026-02-17T23:00Z + + PT15M + + 1 + &xxe; + + + + +''' + } else if (zone == "10YGAP---------G") { + content = ''' + + + 2026-02-16T23:00Z + 2026-02-17T23:00Z + + + + + 2026-02-16T23:00Z + 2026-02-17T23:00Z + + PT15M + + 1 + 81.11 + + + 2 + 82.22 + + + 4 + 84.44 + + + + +''' + } else if (zone == "10YMULTI-------A") { + content = ''' + + + 2026-02-16T23:00Z + 2026-02-17T01:00Z + + + 1 + A03 + + + 2026-02-16T23:00Z + 2026-02-17T00:00Z + + PT15M + + 1 + 51.11 + + + 2 + 52.22 + + + + + 2026-02-17T00:00Z + 2026-02-17T01:00Z + + PT15M + + 1 + 53.33 + + + 2 + 54.44 + + + + +''' + } else { + requestContext.abortWith(Response.serverError().build()) + return + } + + requestContext.abortWith(Response.ok(content, MediaType.APPLICATION_XML_TYPE).build()) + return + } + + requestContext.abortWith(Response.serverError().build()) + } + } + + def "ENTSO-E integration test writes predicted datapoints from publication document"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + EntsoeProtocol.initClient() + + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } + + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } + + when: "an asset attribute is linked to the ENTSO-E agent" + def entsoeLink = new EntsoeAgentLink(agent.id) + entsoeLink.setZone("10YBE----------2") + + asset = new ThingAsset("Energy Price Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, entsoeLink)) + ) + asset = assetStorageService.merge(asset) + + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + and: "the attribute is linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } + + and: "a polling update is triggered" + protocol.updateAllLinkedAttributes() + + then: "predicted datapoints are written using period start and resolution" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef) + assert datapoints.size() == 4 + + def asc = datapoints.sort { it.timestamp } + def start = Instant.parse(DATASET_START).toEpochMilli() + def step = 15 * 60 * 1000L + + assert asc[0].timestamp == start + assert asc[1].timestamp == start + step + assert asc[2].timestamp == start + (2 * step) + assert asc[3].timestamp == start + (3 * step) + + assert (asc[0].value as BigDecimal).compareTo(73.24G) == 0 + assert (asc[1].value as BigDecimal).compareTo(69.79G) == 0 + assert (asc[2].value as BigDecimal).compareTo(65.84G) == 0 + assert (asc[3].value as BigDecimal).compareTo(65.05G) == 0 + } + + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E integration test filters out points in the past when clock is mid-period"() { + given: "the container environment is started with clock in the middle of the dataset period" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + EntsoeProtocol.initClient() + + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } + + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock("2026-02-16T23:20:00.000Z") + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent and linked attribute are created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + def entsoeLink = new EntsoeAgentLink(agent.id) + entsoeLink.setZone("10YBE----------2") + + asset = new ThingAsset("Energy Price Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, entsoeLink)) + ) + asset = assetStorageService.merge(asset) + + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + then: "the protocol is connected and attribute linked" + conditions.eventually { + assert protocol != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } + + when: "a polling update is triggered" + protocol.updateAllLinkedAttributes() + + then: "only datapoints after the cutoff time are stored" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { it.timestamp } + assert datapoints.size() == 2 + + def start = Instant.parse(DATASET_START).toEpochMilli() + def step = 15 * 60 * 1000L + + assert datapoints[0].timestamp == start + (2 * step) + assert datapoints[1].timestamp == start + (3 * step) + assert (datapoints[0].value as BigDecimal).compareTo(65.84G) == 0 + assert (datapoints[1].value as BigDecimal).compareTo(65.05G) == 0 + } + + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E integration test writes predicted datapoints for 2 linked attributes with different zones"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + EntsoeProtocol.initClient() + + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } + + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } + + when: "2 attributes are linked to the same agent with different zones" + def beLink = new EntsoeAgentLink(agent.id) + beLink.setZone("10YBE----------2") + def nlLink = new EntsoeAgentLink(agent.id) + nlLink.setZone("10YNL----------L") + + asset = new ThingAsset("Multi Zone Energy Price Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPriceBe", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, beLink)), + new Attribute<>("energyPriceNl", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, nlLink)) + ) + asset = assetStorageService.merge(asset) + + def beRef = new AttributeRef(asset.id, "energyPriceBe") + def nlRef = new AttributeRef(asset.id, "energyPriceNl") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + and: "both attributes are linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(beRef) + assert protocol.getLinkedAttributes().containsKey(nlRef) + } + + and: "a polling update is triggered" + protocol.updateAllLinkedAttributes() + + then: "each attribute receives predicted datapoints for its configured zone" + conditions.eventually { + List beDatapoints = assetPredictedDatapointService.getDatapoints(beRef).sort { it.timestamp } + List nlDatapoints = assetPredictedDatapointService.getDatapoints(nlRef).sort { it.timestamp } + + assert beDatapoints.size() == 4 + assert nlDatapoints.size() == 4 + + assert (beDatapoints[0].value as BigDecimal).compareTo(73.24G) == 0 + assert (beDatapoints[1].value as BigDecimal).compareTo(69.79G) == 0 + assert (beDatapoints[2].value as BigDecimal).compareTo(65.84G) == 0 + assert (beDatapoints[3].value as BigDecimal).compareTo(65.05G) == 0 + + assert (nlDatapoints[0].value as BigDecimal).compareTo(81.11G) == 0 + assert (nlDatapoints[1].value as BigDecimal).compareTo(82.22G) == 0 + assert (nlDatapoints[2].value as BigDecimal).compareTo(83.33G) == 0 + assert (nlDatapoints[3].value as BigDecimal).compareTo(84.44G) == 0 + } + + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E integration test fetches a shared zone only once per polling cycle"() { + given: "the container environment is started" + requestCountByZone.clear() + requestLog.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + EntsoeProtocol.initClient() + + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } + + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } + + when: "2 attributes are linked to the same agent with the same zone" + def link1 = new EntsoeAgentLink(agent.id) + link1.setZone("10YBE----------2") + def link2 = new EntsoeAgentLink(agent.id) + link2.setZone("10YBE----------2") + + asset = new ThingAsset("Shared Zone Energy Price Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPriceOne", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, link1)), + new Attribute<>("energyPriceTwo", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, link2)) + ) + asset = assetStorageService.merge(asset) + + def refOne = new AttributeRef(asset.id, "energyPriceOne") + def refTwo = new AttributeRef(asset.id, "energyPriceTwo") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + and: "both attributes are linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(refOne) + assert protocol.getLinkedAttributes().containsKey(refTwo) + } + + and: "the request log is cleared before the explicit polling update" + requestLog.clear() + + and: "a polling update is triggered" + protocol.updateAllLinkedAttributes() + + then: "both attributes receive datapoints and the shared zone is requested only once" + conditions.eventually { + List datapointsOne = assetPredictedDatapointService.getDatapoints(refOne).sort { it.timestamp } + List datapointsTwo = assetPredictedDatapointService.getDatapoints(refTwo).sort { it.timestamp } + + assert datapointsOne.size() == 4 + assert datapointsTwo.size() == 4 + assert datapointsOne.collect { [it.timestamp, it.value] } == datapointsTwo.collect { [it.timestamp, it.value] } + + assert requestLog.count { it.zone == "10YBE----------2" } == 1 + } + + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E integration test keeps existing predicted datapoints when subsequent poll fetch fails"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + EntsoeProtocol.initClient() + + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } + + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } + + when: "an attribute is linked to a zone that fails after first successful fetch" + def errLink = new EntsoeAgentLink(agent.id) + errLink.setZone("10YER----------X") + + asset = new ThingAsset("Error On Second Poll Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, errLink)) + ) + asset = assetStorageService.merge(asset) + + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + and: "the attribute is linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } + + and: "first poll succeeds" + protocol.updateAllLinkedAttributes() + + then: "predicted datapoints are written" + def firstSnapshot + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { it.timestamp } + assert datapoints.size() == 4 + assert (datapoints[0].value as BigDecimal).compareTo(91.11G) == 0 + assert (datapoints[1].value as BigDecimal).compareTo(92.22G) == 0 + assert (datapoints[2].value as BigDecimal).compareTo(93.33G) == 0 + assert (datapoints[3].value as BigDecimal).compareTo(94.44G) == 0 + firstSnapshot = datapoints.collect { [it.timestamp, (it.value as BigDecimal)] } + } + + when: "next poll cycle fetch fails" + protocol.updateAllLinkedAttributes() + + then: "existing predicted datapoints remain available" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { it.timestamp } + assert datapoints.size() == 4 + assert firstSnapshot != null + assert datapoints.collect { [it.timestamp, (it.value as BigDecimal)] } == firstSnapshot + } + + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E integration test handles no-data acknowledgement response cleanly"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + EntsoeProtocol.initClient() + + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } + + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } + + when: "an attribute is linked to a zone that returns no-data acknowledgement" + def noDataLink = new EntsoeAgentLink(agent.id) + noDataLink.setZone("10YNDATA-------A") + + asset = new ThingAsset("No Data Zone Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, noDataLink)) + ) + asset = assetStorageService.merge(asset) + + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + and: "the attribute is linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } + + and: "a polling update is triggered" + protocol.updateAllLinkedAttributes() + + then: "no predicted datapoints are written and no exception escapes" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef) + assert datapoints.isEmpty() + } + + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E integration test rejects XML with DTD and external entity declarations"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + EntsoeProtocol.initClient() + + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } + + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } + + when: "an attribute is linked to a zone that returns XML with a DTD and external entity" + def unsafeXmlLink = new EntsoeAgentLink(agent.id) + unsafeXmlLink.setZone("10YXXE---------X") + + asset = new ThingAsset("Unsafe XML Zone Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, unsafeXmlLink)) + ) + asset = assetStorageService.merge(asset) + + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + and: "the attribute is linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } + + and: "a polling update is triggered" + protocol.updateAllLinkedAttributes() + + then: "the unsafe XML is rejected and no predicted datapoints are written" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef) + assert datapoints.isEmpty() + } + + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E integration test keeps position timing when intermediate point is missing"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + EntsoeProtocol.initClient() + + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } + + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent and linked attribute are created for a zone with a missing point position" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + def gapLink = new EntsoeAgentLink(agent.id) + gapLink.setZone("10YGAP---------G") + + asset = new ThingAsset("Gap Position Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, gapLink)) + ) + asset = assetStorageService.merge(asset) + + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + then: "the protocol is connected and attribute linked" + conditions.eventually { + assert protocol != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } + + when: "a polling update is triggered" + protocol.updateAllLinkedAttributes() + + then: "only provided points are stored and their timestamps follow position offsets" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { it.timestamp } + assert datapoints.size() == 3 + + def start = Instant.parse(DATASET_START).toEpochMilli() + def step = 15 * 60 * 1000L + + assert datapoints[0].timestamp == start + assert datapoints[1].timestamp == start + step + assert datapoints[2].timestamp == start + (3 * step) + assert datapoints[2].timestamp - datapoints[1].timestamp == 2 * step + + assert (datapoints[0].value as BigDecimal).compareTo(81.11G) == 0 + assert (datapoints[1].value as BigDecimal).compareTo(82.22G) == 0 + assert (datapoints[2].value as BigDecimal).compareTo(84.44G) == 0 + } + + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E integration test supports multiple periods in a single timeseries"() { + given: "the container environment is started" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + EntsoeProtocol.initClient() + + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } + + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + def assetPredictedDatapointService = container.getService(AssetPredictedDatapointService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent and linked attribute are created for a zone with multiple periods in one timeseries" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + def multiPeriodLink = new EntsoeAgentLink(agent.id) + multiPeriodLink.setZone("10YMULTI-------A") + + asset = new ThingAsset("Multi Period Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, multiPeriodLink)) + ) + asset = assetStorageService.merge(asset) + + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + then: "the protocol is connected and attribute linked" + conditions.eventually { + assert protocol != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } + + when: "a polling update is triggered" + protocol.updateAllLinkedAttributes() + + then: "datapoints are produced from all periods in order" + conditions.eventually { + List datapoints = assetPredictedDatapointService.getDatapoints(attributeRef).sort { it.timestamp } + assert datapoints.size() == 4 + + def firstPeriodStart = Instant.parse(DATASET_START).toEpochMilli() + def secondPeriodStart = Instant.parse("2026-02-17T00:00:00.000Z").toEpochMilli() + def step = 15 * 60 * 1000L + + assert datapoints[0].timestamp == firstPeriodStart + assert datapoints[1].timestamp == firstPeriodStart + step + assert datapoints[2].timestamp == secondPeriodStart + assert datapoints[3].timestamp == secondPeriodStart + step + + assert (datapoints[0].value as BigDecimal).compareTo(51.11G) == 0 + assert (datapoints[1].value as BigDecimal).compareTo(52.22G) == 0 + assert (datapoints[2].value as BigDecimal).compareTo(53.33G) == 0 + assert (datapoints[3].value as BigDecimal).compareTo(54.44G) == 0 + } + + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E agent link validates zone against EIC regex pattern"() { + given: "an ENTSO-E agent link" + def link = new EntsoeAgentLink("agent-id") + + expect: "zone validation follows the EIC regex" + link.setZone(zoneId) + validator.validate(link).isEmpty() == valid + + where: + zoneId || valid + "10YBE----------2" || true + "10YNL----------L" || true + "10YGAP---------G" || true + "1YBE----------2" || false + "10yBE----------2" || false + "10YBE----------" || false + "10YBE----------22" || false + "10YBE_____-----2" || false + "10YNDATA-------A" || true + "10YMULTI-------A" || true + "10YXXE---------X" || true + } + + def "ENTSO-E integration test updates requested period range when clock advances by one day"() { + given: "the container environment is started" + requestCountByZone.clear() + requestLog.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + def periodFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmm").withZone(ZoneId.of("UTC")) + + EntsoeProtocol.initClient() + + if (!EntsoeProtocol.client.get().configuration.isRegistered(mockServer)) { + EntsoeProtocol.client.get().register(mockServer, Integer.MAX_VALUE) + } + + def container = startContainer(defaultConfig(), defaultServices()) + def assetStorageService = container.getService(AssetStorageService.class) + def agentService = container.getService(AgentService.class) + EntsoeAgent agent = null + ThingAsset asset = null + + when: "an ENTSO-E agent is created" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent = assetStorageService.merge(agent) + + then: "the protocol instance for the agent should be created and connected" + conditions.eventually { + assert agentService.getProtocolInstance(agent.id) != null + assert ((EntsoeProtocol) agentService.getProtocolInstance(agent.id)) != null + assert agentService.getAgent(agent.id).getAgentStatus().orElse(null) == ConnectionStatus.CONNECTED + } + + when: "an asset attribute is linked to the ENTSO-E agent" + def link = new EntsoeAgentLink(agent.id) + link.setZone("10YBE----------2") + + asset = new ThingAsset("Time Range Asset") + .setRealm(MASTER_REALM) + .addOrReplaceAttributes( + new Attribute<>("energyPrice", NUMBER) + .addOrReplaceMeta(new MetaItem<>(AGENT_LINK, link)) + ) + asset = assetStorageService.merge(asset) + + def attributeRef = new AttributeRef(asset.id, "energyPrice") + def protocol = (EntsoeProtocol) agentService.getProtocolInstance(agent.id) + + and: "the attribute is linked by protocol" + conditions.eventually { + assert protocol.getLinkedAttributes().containsKey(attributeRef) + } + + and: "an update is triggered at the fixed time" + stopPseudoClock() + setPseudoClock("2026-02-10T10:15:00.000Z") + def fixedNow = Instant.parse("2026-02-10T10:15:00.000Z") + requestLog.clear() + protocol.updateAllLinkedAttributes() + + then: "request periodStart/periodEnd use the fixed clock time" + conditions.eventually { + assert requestLog.any { + it.zone == "10YBE----------2" && + it.periodStart == periodFormatter.format(fixedNow) && + it.periodEnd == periodFormatter.format(fixedNow.plus(1, java.time.temporal.ChronoUnit.DAYS)) + } + } + + when: "clock advances by one day and another update is triggered" + advancePseudoClock(1, TimeUnit.DAYS, container) + def nextNow = fixedNow.plus(1, java.time.temporal.ChronoUnit.DAYS) + requestLog.clear() + protocol.updateAllLinkedAttributes() + + then: "request periodStart/periodEnd are shifted by one day" + conditions.eventually { + assert requestLog.any { + it.zone == "10YBE----------2" && + it.periodStart == periodFormatter.format(nextNow) && + it.periodEnd == periodFormatter.format(nextNow.plus(1, java.time.temporal.ChronoUnit.DAYS)) + } + } + + cleanup: "remove created assets and mock client" + if (asset?.id) { + assetStorageService.delete([asset.id]) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + def "ENTSO-E scheduled polling continues after RuntimeException in updateAllLinkedAttributes"() { + given: "the container environment is started with a pseudo clock" + requestCountByZone.clear() + def conditions = new PollingConditions(timeout: 10, delay: 0.2) + + def container = startContainer(defaultConfig(), defaultServices()) + setPseudoClock(BEFORE_DATASET_START) + def assetStorageService = container.getService(AssetStorageService.class) + EntsoeAgent agent = null + ThrowingEntsoeProtocol protocol = null + + when: "a protocol schedules polling and the scheduled task throws a RuntimeException" + agent = new EntsoeAgent("ENTSO-E Agent") + .setRealm(MASTER_REALM) + .setSecurityToken("test-token") + agent.getAttributes().getOrCreate(org.openremote.model.asset.agent.Agent.POLLING_MILLIS).setValue(1000) + agent = assetStorageService.merge(agent) + + protocol = new ThrowingEntsoeProtocol(agent) + protocol.start(container) + + and: "the clock advances beyond the initial delay and several polling intervals" + advancePseudoClock(10, TimeUnit.SECONDS, container) + + then: "the scheduled task keeps running on the defined schedule despite failures" + conditions.eventually { + assert protocol.invocationCount.get() >= 3 + assert protocol.pollingFuture != null + assert !protocol.pollingFuture.isDone() + } + + when: "the clock advances further" + advancePseudoClock(10, TimeUnit.SECONDS, container) + + then: "more scheduled executions continue to happen" + conditions.eventually { + assert protocol.invocationCount.get() >= 6 + assert !protocol.pollingFuture.isDone() + } + + cleanup: "cancel the scheduled task and remove created assets" + if (protocol?.pollingFuture != null) { + protocol.pollingFuture.cancel(true) + } + if (agent?.id) { + assetStorageService.delete([agent.id]) + } + closeClient() + } + + static class ThrowingEntsoeProtocol extends EntsoeProtocol { + final AtomicInteger invocationCount = new AtomicInteger() + + ThrowingEntsoeProtocol(EntsoeAgent agent) { + super(agent) + } + + @Override + protected boolean healthCheck() { + return true + } + + @Override + protected void doStart(org.openremote.model.Container container) throws Exception { + restartPollingWithInitialDelay() + } + + @Override + protected void updateAllLinkedAttributes() { + invocationCount.incrementAndGet() + throw new RuntimeException("Synthetic scheduled polling failure") + } + } + + protected static void closeClient() { + synchronized (EntsoeProtocol.client) { + def existingClient = EntsoeProtocol.client.getAndSet(null) + if (existingClient != null) { + existingClient.close() + } + } + } +} diff --git a/project.gradle b/project.gradle index cca9830..8e2f64d 100644 --- a/project.gradle +++ b/project.gradle @@ -143,6 +143,11 @@ plugins.withType(JavaPlugin).whenPluginAdded { test { workingDir = findProject(":openremote") != null ? project(":openremote").projectDir : rootProject.projectDir + if (findProject(":openremote") == null) { + // Extension-only repos don't ship the manager UI tree expected by ManagerWebService. + // Point tests at a minimal placeholder docroot so container startup still succeeds. + environment "OR_APP_DOCROOT", rootProject.file("test-support/manager-app").absolutePath + } useJUnitPlatform() testLogging { // set options for log level LIFECYCLE diff --git a/test-support/manager-app/index.html b/test-support/manager-app/index.html new file mode 100644 index 0000000..6dfee5b --- /dev/null +++ b/test-support/manager-app/index.html @@ -0,0 +1,10 @@ + + + + + OpenRemote Test Manager App + + + Test manager app placeholder. + +