From 04c5e9f375119a8c53954743aab606fc5c5ebf22 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Wed, 18 Mar 2026 11:45:30 +0100
Subject: [PATCH 01/13] Set-up to make running tests in extensions repo work
---
project.gradle | 5 +++++
test-support/manager-app/index.html | 10 ++++++++++
2 files changed, 15 insertions(+)
create mode 100644 test-support/manager-app/index.html
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.
+
+
From 745d1a46587f2570b3c0f7b9cfddf266e08cd0f9 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Wed, 18 Mar 2026 11:46:00 +0100
Subject: [PATCH 02/13] Implement an ENTSO-E agent
---
entsoe/build.gradle | 87 ++
.../entsoe/agent/protocol/EntsoeAgent.java | 50 +
.../agent/protocol/EntsoeAgentLink.java | 30 +
.../protocol/EntsoeAgentModelProvider.java | 11 +
.../entsoe/agent/protocol/EntsoeProtocol.java | 382 ++++++++
.../protocol/PublicationMarketDocument.java | 77 ++
.../org.openremote.model.AssetModelProvider | 1 +
.../agent/protocol/EntsoeProtocolTest.groovy | 883 ++++++++++++++++++
8 files changed, 1521 insertions(+)
create mode 100644 entsoe/build.gradle
create mode 100644 entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgent.java
create mode 100644 entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgentLink.java
create mode 100644 entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgentModelProvider.java
create mode 100644 entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocol.java
create mode 100644 entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/PublicationMarketDocument.java
create mode 100644 entsoe/src/main/resources/META-INF/services/org.openremote.model.AssetModelProvider
create mode 100644 entsoe/src/test/groovy/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocolTest.groovy
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/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..7312e45
--- /dev/null
+++ b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgent.java
@@ -0,0 +1,50 @@
+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..624e3b5
--- /dev/null
+++ b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgentLink.java
@@ -0,0 +1,30 @@
+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..278e36b
--- /dev/null
+++ b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeAgentModelProvider.java
@@ -0,0 +1,11 @@
+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..aac6b79
--- /dev/null
+++ b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocol.java
@@ -0,0 +1,382 @@
+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.List;
+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(XMLInputFactory::newFactory);
+
+ // 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::updateAllLinkedAttributes,
+ INITIAL_POLLING_DELAY_MILLIS,
+ pollingMillis,
+ TimeUnit.MILLISECONDS
+ );
+ }
+
+ @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;
+ }
+
+ getLinkedAttributes().forEach(this::updatePricingInformation);
+ }
+
+ protected void updatePricingInformation(AttributeRef attributeRefs, Attribute attribute) {
+ LOG.fine("Updating pricing information data for attribute " + attribute.getName());
+
+ EntsoeAgentLink agentLink = agent.getAgentLink(attribute);
+
+ PublicationMarketDocument doc = fetchPricingInformation(buildApiUrl(agentLink.getZone()));
+ if (doc == null) {
+ LOG.warning(() -> "No ENTSO-E publication document returned for attribute: " + attributeRefs);
+ return;
+ }
+
+ List> predictedDatapoints = buildPredictedDatapoints(doc);
+ if (predictedDatapoints.isEmpty()) {
+ LOG.warning(() -> "No datapoints built from ENTSO-E publication document for attribute: " + attributeRefs);
+ return;
+ }
+
+ predictedDatapointService.updateValues(attributeRefs.getId(), attributeRefs.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(javax.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 (PublicationMarketDocument) PUBLICATION_UNMARSHALLER.get().unmarshal(new StringReader(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 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 EntsoeXmlMeta parseEntsoeXmlMeta(String xml) throws Exception {
+ XMLStreamReader reader = XML_INPUT_FACTORY.get().createXMLStreamReader(new StringReader(xml));
+ String rootElement = null;
+ String reasonText = null;
+ 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 && !text.isBlank()) {
+ reasonText = reasonText == null ? text.trim() : reasonText + text.trim();
+ }
+ } 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();
+ }
+
+ 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()) {
+ PublicationMarketDocument.Period period = timeSeries.getPeriod();
+ 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..04eef60
--- /dev/null
+++ b/entsoe/src/main/java/org/openremote/extension/entsoe/agent/protocol/PublicationMarketDocument.java
@@ -0,0 +1,77 @@
+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 Period period;
+
+ public Period getPeriod() { return period; }
+ }
+
+ @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..6afe391
--- /dev/null
+++ b/entsoe/src/test/groovy/org/openremote/extension/entsoe/agent/protocol/EntsoeProtocolTest.groovy
@@ -0,0 +1,883 @@
+/*
+ * 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.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.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
+
+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