]> git.basschouten.com Git - openhab-addons.git/commitdiff
[awattarar] Add aWATTar API class (#17169)
authortl-photography <thomas@tl-photography.at>
Fri, 30 Aug 2024 16:14:44 +0000 (18:14 +0200)
committerGitHub <noreply@github.com>
Fri, 30 Aug 2024 16:14:44 +0000 (18:14 +0200)
Signed-off-by: Thomas Leber <thomas@tl-photography.at>
bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/AwattarPrice.java
bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/api/AwattarApi.java [new file with mode: 0644]
bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/handler/AwattarBestPriceHandler.java
bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandler.java
bundles/org.openhab.binding.awattar/src/main/resources/OH-INF/i18n/awattar.properties
bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/api/AwattarApiTest.java [new file with mode: 0644]
bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandlerRefreshTest.java
bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/handler/AwattarBridgeHandlerTest.java
bundles/org.openhab.binding.awattar/src/test/resources/org/openhab/binding/awattar/internal/api/api_response.json [new file with mode: 0644]

index fc37dc629130912b0d574dec2c5f7c867f6ac9f8..912350987b1bb8d7a18d604d65ca450c41364b02 100644 (file)
@@ -20,6 +20,12 @@ import org.openhab.binding.awattar.internal.handler.TimeRange;
  *
  * @author Wolfgang Klimt - initial contribution
  * @author Jan N. Klug - Refactored to record
+ *
+ * @param netPrice the net price in €/kWh
+ * @param grossPrice the gross price in €/kWh
+ * @param netTotal the net total price in €
+ * @param grossTotal the gross total price in €
+ * @param timerange the time range of the price
  */
 @NonNullByDefault
 public record AwattarPrice(double netPrice, double grossPrice, double netTotal, double grossTotal,
diff --git a/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/api/AwattarApi.java b/bundles/org.openhab.binding.awattar/src/main/java/org/openhab/binding/awattar/internal/api/AwattarApi.java
new file mode 100644 (file)
index 0000000..5c2b6da
--- /dev/null
@@ -0,0 +1,165 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.awattar.internal.api;
+
+import static org.eclipse.jetty.http.HttpMethod.GET;
+import static org.eclipse.jetty.http.HttpStatus.OK_200;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Comparator;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.openhab.binding.awattar.internal.AwattarBridgeConfiguration;
+import org.openhab.binding.awattar.internal.AwattarPrice;
+import org.openhab.binding.awattar.internal.dto.AwattarApiData;
+import org.openhab.binding.awattar.internal.dto.Datum;
+import org.openhab.binding.awattar.internal.handler.TimeRange;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+
+/**
+ * The {@link AwattarApi} class is responsible for encapsulating the aWATTar API
+ * and providing the data to the bridge.
+ *
+ * @author Thomas Leber - Initial contribution
+ */
+@NonNullByDefault
+public class AwattarApi {
+    private final Logger logger = LoggerFactory.getLogger(AwattarApi.class);
+
+    private static final String URL_DE = "https://api.awattar.de/v1/marketdata";
+    private static final String URL_AT = "https://api.awattar.at/v1/marketdata";
+    private String url = URL_DE;
+
+    private final HttpClient httpClient;
+
+    private double vatFactor;
+    private double basePrice;
+
+    private ZoneId zone;
+
+    private Gson gson;
+
+    /**
+     * Generic exception for the aWATTar API.
+     */
+    public class AwattarApiException extends Exception {
+        private static final long serialVersionUID = 1L;
+
+        public AwattarApiException(String message) {
+            super(message);
+        }
+    }
+
+    /**
+     * Constructor for the aWATTar API.
+     *
+     * @param httpClient the HTTP client to use
+     * @param zone the time zone to use
+     */
+    public AwattarApi(HttpClient httpClient, ZoneId zone, AwattarBridgeConfiguration config) {
+        this.zone = zone;
+        this.httpClient = httpClient;
+
+        this.gson = new Gson();
+
+        vatFactor = 1 + (config.vatPercent / 100);
+        basePrice = config.basePrice;
+
+        if (config.country.equals("DE")) {
+            this.url = URL_DE;
+        } else if (config.country.equals("AT")) {
+            this.url = URL_AT;
+        } else {
+            throw new IllegalArgumentException("Country code must be 'DE' or 'AT'");
+        }
+    }
+
+    /**
+     * Get the data from the aWATTar API.
+     * The data is returned as a sorted set of {@link AwattarPrice} objects.
+     * The data is requested from now minus one day to now plus three days.
+     *
+     * @return the data as a sorted set of {@link AwattarPrice} objects
+     * @throws AwattarApiException
+     * @throws InterruptedException if the thread is interrupted
+     * @throws TimeoutException if the request times out
+     * @throws ExecutionException if the request fails
+     * @throws EmptyDataResponseException if the response is empty
+     */
+    public SortedSet<AwattarPrice> getData() throws AwattarApiException {
+        try {
+            // we start one day in the past to cover ranges that already started yesterday
+            ZonedDateTime zdt = LocalDate.now(zone).atStartOfDay(zone).minusDays(1);
+            long start = zdt.toInstant().toEpochMilli();
+            // Starting from midnight yesterday we add three days so that the range covers
+            // the whole next day.
+            zdt = zdt.plusDays(3);
+            long end = zdt.toInstant().toEpochMilli();
+
+            StringBuilder request = new StringBuilder(url);
+            request.append("?start=").append(start).append("&end=").append(end);
+
+            logger.trace("aWATTar API request: = '{}'", request);
+            ContentResponse contentResponse = httpClient.newRequest(request.toString()).method(GET)
+                    .timeout(10, TimeUnit.SECONDS).send();
+            int httpStatus = contentResponse.getStatus();
+            String content = contentResponse.getContentAsString();
+            logger.trace("aWATTar API response: status = {}, content = '{}'", httpStatus, content);
+
+            if (content == null) {
+                throw new AwattarApiException("@text/error.empty.data");
+            } else if (httpStatus == OK_200) {
+                SortedSet<AwattarPrice> result = new TreeSet<>(Comparator.comparing(AwattarPrice::timerange));
+
+                AwattarApiData apiData = gson.fromJson(content, AwattarApiData.class);
+
+                for (Datum d : apiData.data) {
+                    // the API returns prices in €/MWh, we need €ct/kWh -> divide by 10 (100/1000)
+                    double netMarket = d.marketprice / 10.0;
+                    double grossMarket = netMarket * vatFactor;
+                    double netTotal = netMarket + basePrice;
+                    double grossTotal = netTotal * vatFactor;
+
+                    result.add(new AwattarPrice(netMarket, grossMarket, netTotal, grossTotal,
+                            new TimeRange(d.startTimestamp, d.endTimestamp)));
+                }
+
+                return result;
+            } else {
+                throw new AwattarApiException("@text/warn.awattar.statuscode" + httpStatus);
+            }
+        } catch (ExecutionException e) {
+            throw new AwattarApiException("@text/error.execution");
+        } catch (JsonSyntaxException e) {
+            throw new AwattarApiException("@text/error.json");
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new AwattarApiException("@text/error.interrupted");
+        } catch (TimeoutException e) {
+            throw new AwattarApiException("@text/error.timeout");
+        }
+    }
+}
index 7645308313ece6ecc571ed6a22f463ed24673125..e2fb8585c2c747b9d61d13e1612cb3df81d25237 100644 (file)
@@ -99,7 +99,7 @@ public class AwattarBestPriceHandler extends BaseThingHandler {
                  * here
                  */
                 thingRefresher = scheduler.scheduleAtFixedRate(this::refreshChannels,
-                        getMillisToNextMinute(1, timeZoneProvider), THING_REFRESH_INTERVAL * 1000,
+                        getMillisToNextMinute(1, timeZoneProvider), THING_REFRESH_INTERVAL * 1000L,
                         TimeUnit.MILLISECONDS);
             }
         }
index f33ce3a34dea45448201efc1de669c74d56fbccb..b62909d740f26819600a9c9a77fd28765121d922 100644 (file)
  */
 package org.openhab.binding.awattar.internal.handler;
 
-import static org.eclipse.jetty.http.HttpMethod.GET;
-import static org.eclipse.jetty.http.HttpStatus.OK_200;
-import static org.openhab.binding.awattar.internal.AwattarBindingConstants.*;
+import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_MARKET_NET;
+import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_TOTAL_NET;
 
 import java.time.Instant;
-import java.time.LocalDate;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
-import java.util.Comparator;
 import java.util.SortedSet;
-import java.util.TreeSet;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
 import java.util.function.Function;
 
 import javax.measure.Unit;
@@ -34,11 +28,10 @@ import javax.measure.Unit;
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.eclipse.jetty.client.HttpClient;
-import org.eclipse.jetty.client.api.ContentResponse;
 import org.openhab.binding.awattar.internal.AwattarBridgeConfiguration;
 import org.openhab.binding.awattar.internal.AwattarPrice;
-import org.openhab.binding.awattar.internal.dto.AwattarApiData;
-import org.openhab.binding.awattar.internal.dto.Datum;
+import org.openhab.binding.awattar.internal.api.AwattarApi;
+import org.openhab.binding.awattar.internal.api.AwattarApi.AwattarApiException;
 import org.openhab.core.i18n.TimeZoneProvider;
 import org.openhab.core.library.types.QuantityType;
 import org.openhab.core.library.unit.CurrencyUnits;
@@ -54,13 +47,12 @@ import org.openhab.core.types.util.UnitUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import com.google.gson.Gson;
-import com.google.gson.JsonSyntaxException;
-
 /**
- * The {@link AwattarBridgeHandler} is responsible for retrieving data from the aWATTar API.
+ * The {@link AwattarBridgeHandler} is responsible for retrieving data from the
+ * aWATTar API via the {@link AwattarApi}.
  *
- * The API provides hourly prices for the current day and, starting from 14:00, hourly prices for the next day.
+ * The API provides hourly prices for the current day and, starting from 14:00,
+ * hourly prices for the next day.
  * Check the documentation at <a href="https://www.awattar.de/services/api" />
  *
  *
@@ -73,25 +65,19 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
 
     private final Logger logger = LoggerFactory.getLogger(AwattarBridgeHandler.class);
     private final HttpClient httpClient;
+
     private @Nullable ScheduledFuture<?> dataRefresher;
     private Instant lastRefresh = Instant.EPOCH;
 
-    private static final String URLDE = "https://api.awattar.de/v1/marketdata";
-    private static final String URLAT = "https://api.awattar.at/v1/marketdata";
-    private String url;
-
     // This cache stores price data for up to two days
     private @Nullable SortedSet<AwattarPrice> prices;
-    private double vatFactor = 0;
-    private double basePrice = 0;
     private ZoneId zone;
-    private final TimeZoneProvider timeZoneProvider;
+
+    private @Nullable AwattarApi awattarApi;
 
     public AwattarBridgeHandler(Bridge thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
         super(thing);
         this.httpClient = httpClient;
-        url = URLDE;
-        this.timeZoneProvider = timeZoneProvider;
         zone = timeZoneProvider.getTimeZone();
     }
 
@@ -99,24 +85,15 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
     public void initialize() {
         updateStatus(ThingStatus.UNKNOWN);
         AwattarBridgeConfiguration config = getConfigAs(AwattarBridgeConfiguration.class);
-        vatFactor = 1 + (config.vatPercent / 100);
-        basePrice = config.basePrice;
-        zone = timeZoneProvider.getTimeZone();
-        switch (config.country) {
-            case "DE":
-                url = URLDE;
-                break;
-            case "AT":
-                url = URLAT;
-                break;
-            default:
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
-                        "@text/error.unsupported.country");
-                return;
-        }
 
-        dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000L,
-                TimeUnit.MILLISECONDS);
+        try {
+            awattarApi = new AwattarApi(httpClient, zone, config);
+
+            dataRefresher = scheduler.scheduleWithFixedDelay(this::refreshIfNeeded, 0, DATA_REFRESH_INTERVAL * 1000L,
+                    TimeUnit.MILLISECONDS);
+        } catch (IllegalArgumentException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.unsupported.country");
+        }
     }
 
     @Override
@@ -135,71 +112,36 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
         }
     }
 
+    /**
+     * Refresh the data from the API.
+     *
+     *
+     */
     private void refresh() {
         try {
-            // we start one day in the past to cover ranges that already started yesterday
-            ZonedDateTime zdt = LocalDate.now(zone).atStartOfDay(zone).minusDays(1);
-            long start = zdt.toInstant().toEpochMilli();
-            // Starting from midnight yesterday we add three days so that the range covers the whole next day.
-            zdt = zdt.plusDays(3);
-            long end = zdt.toInstant().toEpochMilli();
-
-            StringBuilder request = new StringBuilder(url);
-            request.append("?start=").append(start).append("&end=").append(end);
-
-            logger.trace("aWATTar API request: = '{}'", request);
-            ContentResponse contentResponse = httpClient.newRequest(request.toString()).method(GET)
-                    .timeout(10, TimeUnit.SECONDS).send();
-            int httpStatus = contentResponse.getStatus();
-            String content = contentResponse.getContentAsString();
-            logger.trace("aWATTar API response: status = {}, content = '{}'", httpStatus, content);
-
-            if (httpStatus == OK_200) {
-                Gson gson = new Gson();
-                SortedSet<AwattarPrice> result = new TreeSet<>(Comparator.comparing(AwattarPrice::timerange));
-                AwattarApiData apiData = gson.fromJson(content, AwattarApiData.class);
-                if (apiData != null) {
-                    TimeSeries netMarketSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
-                    TimeSeries netTotalSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
-
-                    Unit<?> priceUnit = getPriceUnit();
-
-                    for (Datum d : apiData.data) {
-                        double netMarket = d.marketprice / 10.0;
-                        double grossMarket = netMarket * vatFactor;
-                        double netTotal = netMarket + basePrice;
-                        double grossTotal = netTotal * vatFactor;
-                        Instant timestamp = Instant.ofEpochMilli(d.startTimestamp);
-
-                        netMarketSeries.add(timestamp, new QuantityType<>(netMarket / 100.0, priceUnit));
-                        netTotalSeries.add(timestamp, new QuantityType<>(netTotal / 100.0, priceUnit));
-
-                        result.add(new AwattarPrice(netMarket, grossMarket, netTotal, grossTotal,
-                                new TimeRange(d.startTimestamp, d.endTimestamp)));
-                    }
-                    prices = result;
-
-                    // update channels
-                    sendTimeSeries(CHANNEL_MARKET_NET, netMarketSeries);
-                    sendTimeSeries(CHANNEL_TOTAL_NET, netTotalSeries);
-
-                    updateStatus(ThingStatus.ONLINE);
-                } else {
-                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
-                            "@text/error.invalid.data");
-                }
-            } else {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
-                        "@text/warn.awattar.statuscode");
+            // Method is private and only called when dataRefresher is initialized.
+            // DataRefresher is initialized after successful creation of AwattarApi.
+            prices = awattarApi.getData();
+
+            TimeSeries netMarketSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
+            TimeSeries netTotalSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
+
+            Unit<?> priceUnit = getPriceUnit();
+
+            for (AwattarPrice price : prices) {
+                Instant timestamp = Instant.ofEpochMilli(price.timerange().start());
+
+                netMarketSeries.add(timestamp, new QuantityType<>(price.netPrice() / 100.0, priceUnit));
+                netTotalSeries.add(timestamp, new QuantityType<>(price.netTotal() / 100.0, priceUnit));
             }
-        } catch (JsonSyntaxException e) {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.json");
-        } catch (InterruptedException e) {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.interrupted");
-        } catch (ExecutionException e) {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.execution");
-        } catch (TimeoutException e) {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/error.timeout");
+
+            // update channels
+            sendTimeSeries(CHANNEL_MARKET_NET, netMarketSeries);
+            sendTimeSeries(CHANNEL_TOTAL_NET, netTotalSeries);
+
+            updateStatus(ThingStatus.ONLINE);
+        } catch (AwattarApiException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
         }
     }
 
@@ -213,13 +155,13 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
     }
 
     private void createAndSendTimeSeries(String channelId, Function<AwattarPrice, Double> valueFunction) {
-        SortedSet<AwattarPrice> prices = getPrices();
+        SortedSet<AwattarPrice> locPrices = getPrices();
         Unit<?> priceUnit = getPriceUnit();
-        if (prices == null) {
+        if (locPrices == null) {
             return;
         }
         TimeSeries timeSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
-        prices.forEach(p -> {
+        locPrices.forEach(p -> {
             timeSeries.add(Instant.ofEpochMilli(p.timerange().start()),
                     new QuantityType<>(valueFunction.apply(p) / 100.0, priceUnit));
         });
@@ -232,9 +174,12 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
      * The data is refreshed if:
      * - the thing is offline
      * - the local cache is empty
-     * - the current time is after 15:00 and the last refresh was more than an hour ago
-     * - the current time is after 18:00 and the last refresh was more than an hour ago
-     * - the current time is after 21:00 and the last refresh was more than an hour ago
+     * - the current time is after 15:00 and the last refresh was more than an hour
+     * ago
+     * - the current time is after 18:00 and the last refresh was more than an hour
+     * ago
+     * - the current time is after 21:00 and the last refresh was more than an hour
+     * ago
      *
      * @return true if the data needs to be refreshed
      */
@@ -249,10 +194,12 @@ public class AwattarBridgeHandler extends BaseBridgeHandler {
             return true;
         }
 
-        // Note: all this magic is made to avoid refreshing the data too often, since the API is rate-limited
+        // Note: all this magic is made to avoid refreshing the data too often, since
+        // the API is rate-limited
         // to 100 requests per day.
 
-        // do not refresh before 15:00, since the prices for the next day are available only after 14:00
+        // do not refresh before 15:00, since the prices for the next day are available
+        // only after 14:00
         ZonedDateTime now = ZonedDateTime.now(zone);
         if (now.getHour() < 15) {
             return false;
index 6df6886f3af94f65bf497a79586541ec52b6d16e..75fceabe8e26561e1c4d649d0596244965072b25 100644 (file)
@@ -29,7 +29,7 @@ thing-type.config.awattar.bestprice.length.description = The number of hours the
 thing-type.config.awattar.bestprice.consecutive.label = Consecutive
 thing-type.config.awattar.bestprice.consecutive.description = Do the hours need to be consecutive?
 thing-type.config.awattar.bestprice.inverted.label = Inverted
-thing-type.config.awattar.bestprice.inverted.description = Invert the search for the highest price
+thing-type.config.awattar.bestprice.inverted.description = Invert the search to the highest price.
 
 # channel types
 channel-type.awattar.price.label = ct/kWh
@@ -167,7 +167,7 @@ error.json=Invalid JSON response from aWATTar API
 error.interrupted=Communication interrupted
 error.execution=Execution error
 error.timeout=Timeout retrieving prices from aWATTar API
-error.invalid.data=No or invalid data received from aWATTar API
+error.empty.data=No or invalid data received from aWATTar API
 error.length.value=length needs to be > 0 and < duration.
 warn.awattar.statuscode=aWATTar server did not respond with status code 200
 error.start.value=Invalid start value
diff --git a/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/api/AwattarApiTest.java b/bundles/org.openhab.binding.awattar/src/test/java/org/openhab/binding/awattar/internal/api/AwattarApiTest.java
new file mode 100644 (file)
index 0000000..f543757
--- /dev/null
@@ -0,0 +1,155 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.awattar.internal.api;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.ZoneId;
+import java.util.Objects;
+import java.util.SortedSet;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.binding.awattar.internal.AwattarBridgeConfiguration;
+import org.openhab.binding.awattar.internal.AwattarPrice;
+import org.openhab.binding.awattar.internal.api.AwattarApi.AwattarApiException;
+import org.openhab.binding.awattar.internal.handler.AwattarBridgeHandler;
+import org.openhab.binding.awattar.internal.handler.AwattarBridgeHandlerTest;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.test.java.JavaTest;
+
+/**
+ * The {@link AwattarBridgeHandlerTest} contains tests for the
+ * {@link AwattarBridgeHandler}
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@NonNullByDefault
+class AwattarApiTest extends JavaTest {
+    // API Mocks
+    private @Mock @NonNullByDefault({}) HttpClient httpClientMock;
+    private @Mock @NonNullByDefault({}) TimeZoneProvider timeZoneProviderMock;
+    private @Mock @NonNullByDefault({}) Request requestMock;
+    private @Mock @NonNullByDefault({}) ContentResponse contentResponseMock;
+    private @Mock @NonNullByDefault({}) AwattarBridgeConfiguration config;
+
+    // sut
+    private @NonNullByDefault({}) AwattarApi api;
+
+    @BeforeEach
+    public void setUp() throws IOException, ExecutionException, InterruptedException, TimeoutException {
+        try (InputStream inputStream = AwattarApiTest.class.getResourceAsStream("api_response.json")) {
+            if (inputStream == null) {
+                throw new IOException("inputstream is null");
+            }
+            byte[] bytes = inputStream.readAllBytes();
+            if (bytes == null) {
+                throw new IOException("Resulting byte-array empty");
+            }
+            when(contentResponseMock.getContentAsString()).thenReturn(new String(bytes, StandardCharsets.UTF_8));
+        }
+        when(contentResponseMock.getStatus()).thenReturn(HttpStatus.OK_200);
+        when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
+        when(requestMock.method(HttpMethod.GET)).thenReturn(requestMock);
+        when(requestMock.timeout(10, TimeUnit.SECONDS)).thenReturn(requestMock);
+        when(requestMock.send()).thenReturn(contentResponseMock);
+
+        when(timeZoneProviderMock.getTimeZone()).thenReturn(ZoneId.of("GMT+2"));
+
+        config.basePrice = 0.0;
+        config.vatPercent = 0.0;
+        config.country = "DE";
+
+        api = new AwattarApi(httpClientMock, ZoneId.of("GMT+2"), config);
+    }
+
+    @Test
+    void testDeUrl() throws AwattarApiException {
+        api.getData();
+
+        assertThat(httpClientMock.newRequest("https://api.awattar.de/v1/marketdata"), is(requestMock));
+    }
+
+    @Test
+    void testAtUrl() throws AwattarApiException {
+        config.country = "AT";
+        api = new AwattarApi(httpClientMock, ZoneId.of("GMT+2"), config);
+
+        api.getData();
+
+        assertThat(httpClientMock.newRequest("https://api.awattar.at/v1/marketdata"), is(requestMock));
+    }
+
+    @Test
+    void testInvalidCountry() {
+        config.country = "CH";
+
+        IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
+                () -> new AwattarApi(httpClientMock, ZoneId.of("GMT+2"), config));
+        assertThat(thrown.getMessage(), is("Country code must be 'DE' or 'AT'"));
+    }
+
+    @Test
+    void testPricesRetrieval() throws AwattarApiException {
+        SortedSet<AwattarPrice> prices = api.getData();
+
+        assertThat(prices, hasSize(72));
+
+        Objects.requireNonNull(prices);
+
+        // check if first and last element are correct
+        assertThat(prices.first().timerange().start(), is(1718316000000L));
+        assertThat(prices.last().timerange().end(), is(1718575200000L));
+    }
+
+    @Test
+    void testPricesRetrievalEmptyResponse() {
+        when(contentResponseMock.getContentAsString()).thenReturn(null);
+        when(contentResponseMock.getStatus()).thenReturn(HttpStatus.OK_200);
+
+        AwattarApiException thrown = assertThrows(AwattarApiException.class, () -> api.getData());
+        assertThat(thrown.getMessage(), is("@text/error.empty.data"));
+    }
+
+    @Test
+    void testPricesReturnNot200() {
+        when(contentResponseMock.getStatus()).thenReturn(HttpStatus.BAD_REQUEST_400);
+
+        AwattarApiException thrown = assertThrows(AwattarApiException.class, () -> api.getData());
+        assertThat(thrown.getMessage(), is("@text/warn.awattar.statuscode400"));
+    }
+}
index 7cb1ccc46dbbde94e935f5760ff32a41e6d6a8c3..d7f67bda9897d8f341af1ace76ba29625d8d9769 100644 (file)
  */
 package org.openhab.binding.awattar.internal.handler;
 
-import static org.mockito.ArgumentMatchers.*;
-import static org.mockito.Mockito.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
 import java.time.ZoneId;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
+import java.util.List;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jetty.client.HttpClient;
-import org.eclipse.jetty.client.api.ContentResponse;
-import org.eclipse.jetty.client.api.Request;
-import org.eclipse.jetty.http.HttpMethod;
-import org.eclipse.jetty.http.HttpStatus;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.platform.commons.support.HierarchyTraversalMode;
+import org.junit.platform.commons.support.ReflectionSupport;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
 import org.openhab.binding.awattar.internal.AwattarBindingConstants;
+import org.openhab.binding.awattar.internal.api.AwattarApi;
+import org.openhab.binding.awattar.internal.api.AwattarApi.AwattarApiException;
 import org.openhab.core.i18n.TimeZoneProvider;
 import org.openhab.core.test.java.JavaTest;
 import org.openhab.core.thing.Bridge;
@@ -48,14 +47,15 @@ import org.openhab.core.thing.ThingUID;
 import org.openhab.core.thing.binding.ThingHandlerCallback;
 
 /**
- * The {@link AwattarBridgeHandlerRefreshTest} contains tests for the {@link AwattarBridgeHandler} refresh logic.
+ * The {@link AwattarBridgeHandlerRefreshTest} contains tests for the
+ * {@link AwattarBridgeHandler} refresh logic.
  *
  * @author Thomas Leber - Initial contribution
  */
 @ExtendWith(MockitoExtension.class)
 @MockitoSettings(strictness = Strictness.LENIENT)
 @NonNullByDefault
-public class AwattarBridgeHandlerRefreshTest extends JavaTest {
+class AwattarBridgeHandlerRefreshTest extends JavaTest {
     public static final ThingUID BRIDGE_UID = new ThingUID(AwattarBindingConstants.THING_TYPE_BRIDGE, "testBridge");
 
     // bridge mocks
@@ -63,8 +63,7 @@ public class AwattarBridgeHandlerRefreshTest extends JavaTest {
     private @Mock @NonNullByDefault({}) ThingHandlerCallback bridgeCallbackMock;
     private @Mock @NonNullByDefault({}) HttpClient httpClientMock;
     private @Mock @NonNullByDefault({}) TimeZoneProvider timeZoneProviderMock;
-    private @Mock @NonNullByDefault({}) Request requestMock;
-    private @Mock @NonNullByDefault({}) ContentResponse contentResponseMock;
+    private @Mock @NonNullByDefault({}) AwattarApi awattarApiMock;
 
     // best price handler mocks
     private @Mock @NonNullByDefault({}) Thing bestpriceMock;
@@ -73,22 +72,7 @@ public class AwattarBridgeHandlerRefreshTest extends JavaTest {
     private @NonNullByDefault({}) AwattarBridgeHandler bridgeHandler;
 
     @BeforeEach
-    public void setUp() throws IOException, ExecutionException, InterruptedException, TimeoutException {
-        try (InputStream inputStream = AwattarBridgeHandlerRefreshTest.class.getResourceAsStream("api_response.json")) {
-            if (inputStream == null) {
-                throw new IOException("inputstream is null");
-            }
-            byte[] bytes = inputStream.readAllBytes();
-            if (bytes == null) {
-                throw new IOException("Resulting byte-array empty");
-            }
-            when(contentResponseMock.getContentAsString()).thenReturn(new String(bytes, StandardCharsets.UTF_8));
-        }
-        when(contentResponseMock.getStatus()).thenReturn(HttpStatus.OK_200);
-        when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
-        when(requestMock.method(HttpMethod.GET)).thenReturn(requestMock);
-        when(requestMock.timeout(10, TimeUnit.SECONDS)).thenReturn(requestMock);
-        when(requestMock.send()).thenReturn(contentResponseMock);
+    public void setUp() throws IllegalArgumentException, IllegalAccessException {
 
         when(timeZoneProviderMock.getTimeZone()).thenReturn(ZoneId.of("GMT+2"));
 
@@ -96,42 +80,79 @@ public class AwattarBridgeHandlerRefreshTest extends JavaTest {
         bridgeHandler = new AwattarBridgeHandler(bridgeMock, httpClientMock, timeZoneProviderMock);
         bridgeHandler.setCallback(bridgeCallbackMock);
 
-        when(bridgeMock.getHandler()).thenReturn(bridgeHandler);
-
-        // other mocks
-        when(bestpriceMock.getBridgeUID()).thenReturn(BRIDGE_UID);
+        List<Field> fields = ReflectionSupport.findFields(AwattarBridgeHandler.class,
+                field -> field.getName().equals("awattarApi"), HierarchyTraversalMode.BOTTOM_UP);
 
-        when(bestPriceCallbackMock.getBridge(any())).thenReturn(bridgeMock);
-        when(bestPriceCallbackMock.isChannelLinked(any())).thenReturn(true);
+        for (Field field : fields) {
+            field.setAccessible(true);
+            field.set(bridgeHandler, awattarApiMock);
+        }
     }
 
     /**
      * Test the refreshIfNeeded method with a bridge that is offline.
      *
      * @throws SecurityException
+     * @throws AwattarApiException
      */
     @Test
-    void testRefreshIfNeeded_ThingOffline() throws SecurityException {
+    void testRefreshIfNeeded_ThingOffline() throws SecurityException, AwattarApiException {
         when(bridgeMock.getStatus()).thenReturn(ThingStatus.OFFLINE);
 
         bridgeHandler.refreshIfNeeded();
 
         verify(bridgeCallbackMock).statusUpdated(bridgeMock,
                 new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null));
+        verify(awattarApiMock).getData();
     }
 
     /**
-     * Test the refreshIfNeeded method with a bridge that is online and the data is empty.
+     * Test the refreshIfNeeded method with a bridge that is online and the data is
+     * empty.
      *
      * @throws SecurityException
+     * @throws AwattarApiException
      */
     @Test
-    void testRefreshIfNeeded_DataEmptry() throws SecurityException {
+    void testRefreshIfNeeded_DataEmpty() throws SecurityException, AwattarApiException {
         when(bridgeMock.getStatus()).thenReturn(ThingStatus.ONLINE);
 
         bridgeHandler.refreshIfNeeded();
 
         verify(bridgeCallbackMock).statusUpdated(bridgeMock,
                 new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null));
+        verify(awattarApiMock).getData();
+    }
+
+    @Test
+    void testNeedRefresh_ThingOffline() throws SecurityException {
+        when(bridgeMock.getStatus()).thenReturn(ThingStatus.OFFLINE);
+
+        // get private method via reflection
+        Method method = ReflectionSupport.findMethod(AwattarBridgeHandler.class, "needRefresh", "").get();
+
+        boolean result = (boolean) ReflectionSupport.invokeMethod(method, bridgeHandler);
+
+        assertThat(result, is(true));
+    }
+
+    @Test
+    void testNeedRefresh_DataEmpty() throws SecurityException, IllegalArgumentException, IllegalAccessException {
+        when(bridgeMock.getStatus()).thenReturn(ThingStatus.ONLINE);
+
+        List<Field> fields = ReflectionSupport.findFields(AwattarBridgeHandler.class,
+                field -> field.getName().equals("prices"), HierarchyTraversalMode.BOTTOM_UP);
+
+        for (Field field : fields) {
+            field.setAccessible(true);
+            field.set(bridgeHandler, null);
+        }
+
+        // get private method via reflection
+        Method method = ReflectionSupport.findMethod(AwattarBridgeHandler.class, "needRefresh", "").get();
+
+        boolean result = (boolean) ReflectionSupport.invokeMethod(method, bridgeHandler);
+
+        assertThat(result, is(true));
     }
 }
index b90d3f1ed0f5df4f9ec0108264772607335e9e77..5c1ce6c705e8cba0ce19767e0ba7f2dcf3fb621c 100644 (file)
 package org.openhab.binding.awattar.internal.handler;
 
 import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.*;
-import static org.mockito.ArgumentMatchers.*;
-import static org.mockito.Mockito.*;
-import static org.openhab.binding.awattar.internal.AwattarBindingConstants.*;
+import static org.hamcrest.Matchers.closeTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_END;
+import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_HOURS;
+import static org.openhab.binding.awattar.internal.AwattarBindingConstants.CHANNEL_START;
 
 import java.io.IOException;
 import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
+import java.lang.reflect.Field;
 import java.time.ZoneId;
+import java.util.Comparator;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.SortedSet;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
+import java.util.TreeSet;
 import java.util.stream.Stream;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jetty.client.HttpClient;
-import org.eclipse.jetty.client.api.ContentResponse;
-import org.eclipse.jetty.client.api.Request;
-import org.eclipse.jetty.http.HttpMethod;
-import org.eclipse.jetty.http.HttpStatus;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.platform.commons.support.HierarchyTraversalMode;
+import org.junit.platform.commons.support.ReflectionSupport;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
 import org.openhab.binding.awattar.internal.AwattarBindingConstants;
 import org.openhab.binding.awattar.internal.AwattarPrice;
+import org.openhab.binding.awattar.internal.api.AwattarApi;
+import org.openhab.binding.awattar.internal.api.AwattarApi.AwattarApiException;
+import org.openhab.binding.awattar.internal.dto.AwattarApiData;
 import org.openhab.core.config.core.Configuration;
 import org.openhab.core.i18n.TimeZoneProvider;
 import org.openhab.core.library.types.DateTimeType;
@@ -60,6 +67,8 @@ import org.openhab.core.thing.ThingUID;
 import org.openhab.core.thing.binding.ThingHandlerCallback;
 import org.openhab.core.types.State;
 
+import com.google.gson.Gson;
+
 /**
  * The {@link AwattarBridgeHandlerTest} contains tests for the {@link AwattarBridgeHandler}
  *
@@ -76,8 +85,7 @@ public class AwattarBridgeHandlerTest extends JavaTest {
     private @Mock @NonNullByDefault({}) ThingHandlerCallback bridgeCallbackMock;
     private @Mock @NonNullByDefault({}) HttpClient httpClientMock;
     private @Mock @NonNullByDefault({}) TimeZoneProvider timeZoneProviderMock;
-    private @Mock @NonNullByDefault({}) Request requestMock;
-    private @Mock @NonNullByDefault({}) ContentResponse contentResponseMock;
+    private @Mock @NonNullByDefault({}) AwattarApi awattarApiMock;
 
     // best price handler mocks
     private @Mock @NonNullByDefault({}) Thing bestpriceMock;
@@ -86,69 +94,64 @@ public class AwattarBridgeHandlerTest extends JavaTest {
     private @NonNullByDefault({}) AwattarBridgeHandler bridgeHandler;
 
     @BeforeEach
-    public void setUp() throws IOException, ExecutionException, InterruptedException, TimeoutException {
+    public void setUp() throws IOException, IllegalArgumentException, IllegalAccessException, AwattarApiException {
+
+        // mock the API response
         try (InputStream inputStream = AwattarBridgeHandlerTest.class.getResourceAsStream("api_response.json")) {
-            if (inputStream == null) {
-                throw new IOException("inputstream is null");
-            }
-            byte[] bytes = inputStream.readAllBytes();
-            if (bytes == null) {
-                throw new IOException("Resulting byte-array empty");
-            }
-            when(contentResponseMock.getContentAsString()).thenReturn(new String(bytes, StandardCharsets.UTF_8));
+            SortedSet<AwattarPrice> result = new TreeSet<>(Comparator.comparing(AwattarPrice::timerange));
+            Gson gson = new Gson();
+
+            String json = new String(inputStream.readAllBytes());
+
+            // read json file into sorted set of AwattarPrices
+            AwattarApiData apiData = gson.fromJson(json, AwattarApiData.class);
+            apiData.data.forEach(datum -> result.add(new AwattarPrice(datum.marketprice, datum.marketprice,
+                    datum.marketprice, datum.marketprice, new TimeRange(datum.startTimestamp, datum.endTimestamp))));
+            when(awattarApiMock.getData()).thenReturn(result);
         }
-        when(contentResponseMock.getStatus()).thenReturn(HttpStatus.OK_200);
-        when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
-        when(requestMock.method(HttpMethod.GET)).thenReturn(requestMock);
-        when(requestMock.timeout(10, TimeUnit.SECONDS)).thenReturn(requestMock);
-        when(requestMock.send()).thenReturn(contentResponseMock);
 
         when(timeZoneProviderMock.getTimeZone()).thenReturn(ZoneId.of("GMT+2"));
 
         when(bridgeMock.getUID()).thenReturn(BRIDGE_UID);
         bridgeHandler = new AwattarBridgeHandler(bridgeMock, httpClientMock, timeZoneProviderMock);
         bridgeHandler.setCallback(bridgeCallbackMock);
+
+        // mock the private field awattarApi
+        List<Field> fields = ReflectionSupport.findFields(AwattarBridgeHandler.class,
+                field -> field.getName().equals("awattarApi"), HierarchyTraversalMode.BOTTOM_UP);
+
+        for (Field field : fields) {
+            field.setAccessible(true);
+            field.set(bridgeHandler, awattarApiMock);
+        }
+
         bridgeHandler.refreshIfNeeded();
         when(bridgeMock.getHandler()).thenReturn(bridgeHandler);
 
         // other mocks
         when(bestpriceMock.getBridgeUID()).thenReturn(BRIDGE_UID);
-
         when(bestPriceCallbackMock.getBridge(any())).thenReturn(bridgeMock);
         when(bestPriceCallbackMock.isChannelLinked(any())).thenReturn(true);
     }
 
     @Test
-    public void testPricesRetrieval() {
-        SortedSet<AwattarPrice> prices = bridgeHandler.getPrices();
-
-        assertThat(prices, hasSize(72));
-
-        Objects.requireNonNull(prices);
-
-        // check if first and last element are correct
-        assertThat(prices.first().timerange().start(), is(1718316000000L));
-        assertThat(prices.last().timerange().end(), is(1718575200000L));
-    }
-
-    @Test
-    public void testGetPriceForSuccess() {
+    void testGetPriceForSuccess() {
         AwattarPrice price = bridgeHandler.getPriceFor(1718503200000L);
 
         assertThat(price, is(notNullValue()));
         Objects.requireNonNull(price);
-        assertThat(price.netPrice(), is(closeTo(0.219, 0.001)));
+        assertThat(price.netPrice(), is(closeTo(2.19, 0.001)));
     }
 
     @Test
-    public void testGetPriceForFail() {
+    void testGetPriceForFail() {
         AwattarPrice price = bridgeHandler.getPriceFor(1518503200000L);
 
         assertThat(price, is(nullValue()));
     }
 
     @Test
-    public void testContainsPrizeFor() {
+    void testContainsPrizeFor() {
         assertThat(bridgeHandler.containsPriceFor(1618503200000L), is(false));
         assertThat(bridgeHandler.containsPriceFor(1718503200000L), is(true));
         assertThat(bridgeHandler.containsPriceFor(1818503200000L), is(false));
@@ -172,7 +175,7 @@ public class AwattarBridgeHandlerTest extends JavaTest {
 
     @ParameterizedTest
     @MethodSource
-    public void testBestpriceHandler(int length, boolean consecutive, String channelId, State expectedState) {
+    void testBestpriceHandler(int length, boolean consecutive, String channelId, State expectedState) {
         ThingUID bestPriceUid = new ThingUID(AwattarBindingConstants.THING_TYPE_BESTPRICE, "foo");
         Map<String, Object> config = Map.of("length", length, "consecutive", consecutive);
         when(bestpriceMock.getConfiguration()).thenReturn(new Configuration(config));
diff --git a/bundles/org.openhab.binding.awattar/src/test/resources/org/openhab/binding/awattar/internal/api/api_response.json b/bundles/org.openhab.binding.awattar/src/test/resources/org/openhab/binding/awattar/internal/api/api_response.json
new file mode 100644 (file)
index 0000000..8f0fbbe
--- /dev/null
@@ -0,0 +1,438 @@
+{
+  "object": "list",
+  "data": [
+    {
+      "start_timestamp": 1718316000000,
+      "end_timestamp": 1718319600000,
+      "marketprice": 83.13,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718319600000,
+      "end_timestamp": 1718323200000,
+      "marketprice": 71.45,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718323200000,
+      "end_timestamp": 1718326800000,
+      "marketprice": 63.93,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718326800000,
+      "end_timestamp": 1718330400000,
+      "marketprice": 59.53,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718330400000,
+      "end_timestamp": 1718334000000,
+      "marketprice": 55.82,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718334000000,
+      "end_timestamp": 1718337600000,
+      "marketprice": 64.22,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718337600000,
+      "end_timestamp": 1718341200000,
+      "marketprice": 85.01,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718341200000,
+      "end_timestamp": 1718344800000,
+      "marketprice": 100.95,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718344800000,
+      "end_timestamp": 1718348400000,
+      "marketprice": 104.99,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718348400000,
+      "end_timestamp": 1718352000000,
+      "marketprice": 102.54,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718352000000,
+      "end_timestamp": 1718355600000,
+      "marketprice": 82.18,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718355600000,
+      "end_timestamp": 1718359200000,
+      "marketprice": 68.1,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718359200000,
+      "end_timestamp": 1718362800000,
+      "marketprice": 60.88,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718362800000,
+      "end_timestamp": 1718366400000,
+      "marketprice": 47.46,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718366400000,
+      "end_timestamp": 1718370000000,
+      "marketprice": 40.74,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718370000000,
+      "end_timestamp": 1718373600000,
+      "marketprice": 41,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718373600000,
+      "end_timestamp": 1718377200000,
+      "marketprice": 60.31,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718377200000,
+      "end_timestamp": 1718380800000,
+      "marketprice": 75,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718380800000,
+      "end_timestamp": 1718384400000,
+      "marketprice": 90.98,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718384400000,
+      "end_timestamp": 1718388000000,
+      "marketprice": 136,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718388000000,
+      "end_timestamp": 1718391600000,
+      "marketprice": 127.31,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718391600000,
+      "end_timestamp": 1718395200000,
+      "marketprice": 117.12,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718395200000,
+      "end_timestamp": 1718398800000,
+      "marketprice": 83.41,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718398800000,
+      "end_timestamp": 1718402400000,
+      "marketprice": 59.42,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718402400000,
+      "end_timestamp": 1718406000000,
+      "marketprice": 60.68,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718406000000,
+      "end_timestamp": 1718409600000,
+      "marketprice": 41.04,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718409600000,
+      "end_timestamp": 1718413200000,
+      "marketprice": 29.97,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718413200000,
+      "end_timestamp": 1718416800000,
+      "marketprice": 28.86,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718416800000,
+      "end_timestamp": 1718420400000,
+      "marketprice": 22.51,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718420400000,
+      "end_timestamp": 1718424000000,
+      "marketprice": 10.04,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718424000000,
+      "end_timestamp": 1718427600000,
+      "marketprice": 1.54,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718427600000,
+      "end_timestamp": 1718431200000,
+      "marketprice": 0.09,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718431200000,
+      "end_timestamp": 1718434800000,
+      "marketprice": 0,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718434800000,
+      "end_timestamp": 1718438400000,
+      "marketprice": -0.06,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718438400000,
+      "end_timestamp": 1718442000000,
+      "marketprice": -10.08,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718442000000,
+      "end_timestamp": 1718445600000,
+      "marketprice": -29.04,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718445600000,
+      "end_timestamp": 1718449200000,
+      "marketprice": -44.92,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718449200000,
+      "end_timestamp": 1718452800000,
+      "marketprice": -65.46,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718452800000,
+      "end_timestamp": 1718456400000,
+      "marketprice": -80.01,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718456400000,
+      "end_timestamp": 1718460000000,
+      "marketprice": -56.23,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718460000000,
+      "end_timestamp": 1718463600000,
+      "marketprice": -29.53,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718463600000,
+      "end_timestamp": 1718467200000,
+      "marketprice": -4.84,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718467200000,
+      "end_timestamp": 1718470800000,
+      "marketprice": -0.01,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718470800000,
+      "end_timestamp": 1718474400000,
+      "marketprice": 40,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718474400000,
+      "end_timestamp": 1718478000000,
+      "marketprice": 84.28,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718478000000,
+      "end_timestamp": 1718481600000,
+      "marketprice": 79.92,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718481600000,
+      "end_timestamp": 1718485200000,
+      "marketprice": 64.3,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718485200000,
+      "end_timestamp": 1718488800000,
+      "marketprice": 40.4,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718488800000,
+      "end_timestamp": 1718492400000,
+      "marketprice": 24.91,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718492400000,
+      "end_timestamp": 1718496000000,
+      "marketprice": 10.36,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718496000000,
+      "end_timestamp": 1718499600000,
+      "marketprice": 4.92,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718499600000,
+      "end_timestamp": 1718503200000,
+      "marketprice": 2.92,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718503200000,
+      "end_timestamp": 1718506800000,
+      "marketprice": 2.19,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718506800000,
+      "end_timestamp": 1718510400000,
+      "marketprice": 2.53,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718510400000,
+      "end_timestamp": 1718514000000,
+      "marketprice": 2.95,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718514000000,
+      "end_timestamp": 1718517600000,
+      "marketprice": 0.69,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718517600000,
+      "end_timestamp": 1718521200000,
+      "marketprice": -0.02,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718521200000,
+      "end_timestamp": 1718524800000,
+      "marketprice": -1.28,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718524800000,
+      "end_timestamp": 1718528400000,
+      "marketprice": -10,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718528400000,
+      "end_timestamp": 1718532000000,
+      "marketprice": -13.33,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718532000000,
+      "end_timestamp": 1718535600000,
+      "marketprice": -20.01,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718535600000,
+      "end_timestamp": 1718539200000,
+      "marketprice": -30.01,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718539200000,
+      "end_timestamp": 1718542800000,
+      "marketprice": -35.67,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718542800000,
+      "end_timestamp": 1718546400000,
+      "marketprice": -29.04,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718546400000,
+      "end_timestamp": 1718550000000,
+      "marketprice": -10.14,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718550000000,
+      "end_timestamp": 1718553600000,
+      "marketprice": -2.34,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718553600000,
+      "end_timestamp": 1718557200000,
+      "marketprice": 56.22,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718557200000,
+      "end_timestamp": 1718560800000,
+      "marketprice": 99.65,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718560800000,
+      "end_timestamp": 1718564400000,
+      "marketprice": 119.15,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718564400000,
+      "end_timestamp": 1718568000000,
+      "marketprice": 124.28,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718568000000,
+      "end_timestamp": 1718571600000,
+      "marketprice": 120.34,
+      "unit": "Eur/MWh"
+    },
+    {
+      "start_timestamp": 1718571600000,
+      "end_timestamp": 1718575200000,
+      "marketprice": 94.44,
+      "unit": "Eur/MWh"
+    }
+  ],
+  "url": "/de/v1/marketdata"
+}
\ No newline at end of file